Merge branch 'master' into 27574-pipelines-empty-state
* master: (23 commits) Resolve "Extract logic of who should receive notification into separate classes" Remove UJS actions from pipelines tables Added Gitlab::Database.config Fix time-sensitive helper spec Updates realtime documentation for the Frontend Add ability to disable Merge Request URL on push Added labels to the issue web hook documentation blurb in issue template Add a new have_html_escaped_body_text that match an HTML-escaped text Stop CI notification showing when status is nil Refactor award emojis document Do not use Ruby Timeout module in GitLab QA Make sure alias email would never match: Make the test less time sensitive by extending 0.2 Restore sub-nav for empty project Fix Unicode 1.1 emojis Use "branch_name" instead "branch" on V3 branch creation API Fixed eslint Catches errors when generating lists Resolve GitLab QA cold boot problems on entry page ...
|
@ -5,3 +5,13 @@
|
|||
### Proposal
|
||||
|
||||
### Links / references
|
||||
|
||||
### Documentation blurb
|
||||
|
||||
(Write the start of the documentation of this feature here, include:
|
||||
|
||||
1. Why should someone use it; what's the underlying problem.
|
||||
2. What is the solution.
|
||||
3. How does someone use this
|
||||
|
||||
During implementation, this can then be copied and used as a starter for the documentation.)
|
||||
|
|
2
Gemfile
|
@ -352,4 +352,4 @@ gem 'vmstat', '~> 2.3.0'
|
|||
gem 'sys-filesystem', '~> 1.1.6'
|
||||
|
||||
# Gitaly GRPC client
|
||||
gem 'gitaly', '~> 0.2.1'
|
||||
gem 'gitaly', '~> 0.3.0'
|
||||
|
|
|
@ -250,7 +250,7 @@ GEM
|
|||
json
|
||||
get_process_mem (0.2.0)
|
||||
gherkin-ruby (0.3.2)
|
||||
gitaly (0.2.1)
|
||||
gitaly (0.3.0)
|
||||
google-protobuf (~> 3.1)
|
||||
grpc (~> 1.0)
|
||||
github-linguist (4.7.6)
|
||||
|
@ -896,7 +896,7 @@ DEPENDENCIES
|
|||
fuubar (~> 2.0.0)
|
||||
gemnasium-gitlab-service (~> 0.2)
|
||||
gemojione (~> 3.0)
|
||||
gitaly (~> 0.2.1)
|
||||
gitaly (~> 0.3.0)
|
||||
github-linguist (~> 4.7.0)
|
||||
gitlab-flowdock-git-hook (~> 1.0.1)
|
||||
gitlab-markup (~> 1.5.1)
|
||||
|
|
|
@ -2,7 +2,8 @@
|
|||
/* global Vue */
|
||||
/* global Sortable */
|
||||
|
||||
require('./board_blank_state');
|
||||
import boardBlankState from './board_blank_state';
|
||||
|
||||
require('./board_delete');
|
||||
require('./board_list');
|
||||
|
||||
|
@ -17,7 +18,7 @@ require('./board_list');
|
|||
components: {
|
||||
'board-list': gl.issueBoards.BoardList,
|
||||
'board-delete': gl.issueBoards.BoardDelete,
|
||||
'board-blank-state': gl.issueBoards.BoardBlankState
|
||||
boardBlankState,
|
||||
},
|
||||
props: {
|
||||
list: Object,
|
||||
|
|
|
@ -1,53 +1,84 @@
|
|||
/* eslint-disable space-before-function-paren, comma-dangle */
|
||||
/* global Vue */
|
||||
/* global ListLabel */
|
||||
/* global Cookies */
|
||||
const Store = gl.issueBoards.BoardsStore;
|
||||
|
||||
(() => {
|
||||
const Store = gl.issueBoards.BoardsStore;
|
||||
export default {
|
||||
template: `
|
||||
<div class="board-blank-state">
|
||||
<p>
|
||||
Add the following default lists to your Issue Board with one click:
|
||||
</p>
|
||||
<ul class="board-blank-state-list">
|
||||
<li v-for="label in predefinedLabels">
|
||||
<span
|
||||
class="label-color"
|
||||
:style="{ backgroundColor: label.color }">
|
||||
</span>
|
||||
{{ label.title }}
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Starting out with the default set of lists will get you right on the way to making the most of your board.
|
||||
</p>
|
||||
<button
|
||||
class="btn btn-create btn-inverted btn-block"
|
||||
type="button"
|
||||
@click.stop="addDefaultLists">
|
||||
Add default lists
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-default btn-block"
|
||||
type="button"
|
||||
@click.stop="clearBlankState">
|
||||
Nevermind, I'll use my own
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
predefinedLabels: [
|
||||
new ListLabel({ title: 'To Do', color: '#F0AD4E' }),
|
||||
new ListLabel({ title: 'Doing', color: '#5CB85C' }),
|
||||
],
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
addDefaultLists() {
|
||||
this.clearBlankState();
|
||||
|
||||
window.gl = window.gl || {};
|
||||
window.gl.issueBoards = window.gl.issueBoards || {};
|
||||
|
||||
gl.issueBoards.BoardBlankState = Vue.extend({
|
||||
data () {
|
||||
return {
|
||||
predefinedLabels: [
|
||||
new ListLabel({ title: 'To Do', color: '#F0AD4E' }),
|
||||
new ListLabel({ title: 'Doing', color: '#5CB85C' })
|
||||
]
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
addDefaultLists () {
|
||||
this.clearBlankState();
|
||||
|
||||
this.predefinedLabels.forEach((label, i) => {
|
||||
Store.addList({
|
||||
this.predefinedLabels.forEach((label, i) => {
|
||||
Store.addList({
|
||||
title: label.title,
|
||||
position: i,
|
||||
list_type: 'label',
|
||||
label: {
|
||||
title: label.title,
|
||||
position: i,
|
||||
list_type: 'label',
|
||||
label: {
|
||||
title: label.title,
|
||||
color: label.color
|
||||
}
|
||||
});
|
||||
color: label.color,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Store.state.lists = _.sortBy(Store.state.lists, 'position');
|
||||
Store.state.lists = _.sortBy(Store.state.lists, 'position');
|
||||
|
||||
// Save the labels
|
||||
gl.boardService.generateDefaultLists()
|
||||
.then((resp) => {
|
||||
resp.json().forEach((listObj) => {
|
||||
const list = Store.findList('title', listObj.title);
|
||||
// Save the labels
|
||||
gl.boardService.generateDefaultLists()
|
||||
.then((resp) => {
|
||||
resp.json().forEach((listObj) => {
|
||||
const list = Store.findList('title', listObj.title);
|
||||
|
||||
list.id = listObj.id;
|
||||
list.label.id = listObj.label.id;
|
||||
list.getIssues();
|
||||
});
|
||||
list.id = listObj.id;
|
||||
list.label.id = listObj.label.id;
|
||||
list.getIssues();
|
||||
});
|
||||
},
|
||||
clearBlankState: Store.removeBlankState.bind(Store)
|
||||
}
|
||||
});
|
||||
})();
|
||||
})
|
||||
.catch(() => {
|
||||
Store.removeList(undefined, 'label');
|
||||
Cookies.remove('issue_board_welcome_hidden', {
|
||||
path: '',
|
||||
});
|
||||
Store.addBlankState();
|
||||
});
|
||||
},
|
||||
clearBlankState: Store.removeBlankState.bind(Store),
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
/* eslint-disable no-new, no-param-reassign */
|
||||
/* global Vue, CommitsPipelineStore, PipelinesService, Flash */
|
||||
/* eslint-disable no-param-reassign */
|
||||
import CommitPipelinesTable from './pipelines_table';
|
||||
|
||||
window.Vue = require('vue');
|
||||
require('./pipelines_table');
|
||||
window.Vue.use(require('vue-resource'));
|
||||
|
||||
/**
|
||||
* Commits View > Pipelines Tab > Pipelines Table.
|
||||
* Merge Request View > Pipelines Tab > Pipelines Table.
|
||||
|
@ -21,7 +22,7 @@ $(() => {
|
|||
}
|
||||
|
||||
const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
|
||||
gl.commits.pipelines.PipelinesTableBundle = new gl.commits.pipelines.PipelinesTableView();
|
||||
gl.commits.pipelines.PipelinesTableBundle = new CommitPipelinesTable();
|
||||
|
||||
if (pipelineTableViewEl && pipelineTableViewEl.dataset.disableInitialization === undefined) {
|
||||
gl.commits.pipelines.PipelinesTableBundle.$mount(pipelineTableViewEl);
|
||||
|
|
|
@ -1,44 +0,0 @@
|
|||
/* globals Vue */
|
||||
/* eslint-disable no-unused-vars, no-param-reassign */
|
||||
|
||||
/**
|
||||
* Pipelines service.
|
||||
*
|
||||
* Used to fetch the data used to render the pipelines table.
|
||||
* Uses Vue.Resource
|
||||
*/
|
||||
class PipelinesService {
|
||||
|
||||
/**
|
||||
* FIXME: The url provided to request the pipelines in the new merge request
|
||||
* page already has `.json`.
|
||||
* This should be fixed when the endpoint is improved.
|
||||
*
|
||||
* @param {String} root
|
||||
*/
|
||||
constructor(root) {
|
||||
let endpoint;
|
||||
|
||||
if (root.indexOf('.json') === -1) {
|
||||
endpoint = `${root}.json`;
|
||||
} else {
|
||||
endpoint = root;
|
||||
}
|
||||
this.pipelines = Vue.resource(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the root param provided when the class is initialized, will
|
||||
* make a GET request.
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
all() {
|
||||
return this.pipelines.get();
|
||||
}
|
||||
}
|
||||
|
||||
window.gl = window.gl || {};
|
||||
gl.commits = gl.commits || {};
|
||||
gl.commits.pipelines = gl.commits.pipelines || {};
|
||||
gl.commits.pipelines.PipelinesService = PipelinesService;
|
|
@ -1,13 +1,12 @@
|
|||
/* eslint-disable no-new, no-param-reassign */
|
||||
/* global Vue, CommitsPipelineStore, PipelinesService, Flash */
|
||||
|
||||
window.Vue = require('vue');
|
||||
window.Vue.use(require('vue-resource'));
|
||||
require('../../lib/utils/common_utils');
|
||||
require('../../vue_shared/vue_resource_interceptor');
|
||||
require('../../vue_shared/components/pipelines_table');
|
||||
require('./pipelines_service');
|
||||
const PipelineStore = require('./pipelines_store');
|
||||
/* eslint-disable no-new*/
|
||||
/* global Flash */
|
||||
import Vue from 'vue';
|
||||
import PipelinesTableComponent from '../../vue_shared/components/pipelines_table';
|
||||
import PipelinesService from '../../vue_pipelines_index/services/pipelines_service';
|
||||
import PipelineStore from '../../vue_pipelines_index/stores/pipelines_store';
|
||||
import eventHub from '../../vue_pipelines_index/event_hub';
|
||||
import '../../lib/utils/common_utils';
|
||||
import '../../vue_shared/vue_resource_interceptor';
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -20,48 +19,59 @@ const PipelineStore = require('./pipelines_store');
|
|||
* as soon as we have Webpack and can load them directly into JS files.
|
||||
*/
|
||||
|
||||
(() => {
|
||||
window.gl = window.gl || {};
|
||||
gl.commits = gl.commits || {};
|
||||
gl.commits.pipelines = gl.commits.pipelines || {};
|
||||
export default Vue.component('pipelines-table', {
|
||||
components: {
|
||||
'pipelines-table-component': PipelinesTableComponent,
|
||||
},
|
||||
|
||||
gl.commits.pipelines.PipelinesTableView = Vue.component('pipelines-table', {
|
||||
/**
|
||||
* Accesses the DOM to provide the needed data.
|
||||
* Returns the necessary props to render `pipelines-table-component` component.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
data() {
|
||||
const pipelinesTableData = document.querySelector('#commit-pipeline-table-view').dataset;
|
||||
const store = new PipelineStore();
|
||||
|
||||
components: {
|
||||
'pipelines-table-component': gl.pipelines.PipelinesTableComponent,
|
||||
},
|
||||
return {
|
||||
endpoint: pipelinesTableData.endpoint,
|
||||
store,
|
||||
state: store.state,
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Accesses the DOM to provide the needed data.
|
||||
* Returns the necessary props to render `pipelines-table-component` component.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
data() {
|
||||
const pipelinesTableData = document.querySelector('#commit-pipeline-table-view').dataset;
|
||||
const store = new PipelineStore();
|
||||
/**
|
||||
* When the component is about to be mounted, tell the service to fetch the data
|
||||
*
|
||||
* A request to fetch the pipelines will be made.
|
||||
* In case of a successfull response we will store the data in the provided
|
||||
* store, in case of a failed response we need to warn the user.
|
||||
*
|
||||
*/
|
||||
beforeMount() {
|
||||
this.service = new PipelinesService(this.endpoint);
|
||||
|
||||
return {
|
||||
endpoint: pipelinesTableData.endpoint,
|
||||
store,
|
||||
state: store.state,
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
this.fetchPipelines();
|
||||
|
||||
/**
|
||||
* When the component is about to be mounted, tell the service to fetch the data
|
||||
*
|
||||
* A request to fetch the pipelines will be made.
|
||||
* In case of a successfull response we will store the data in the provided
|
||||
* store, in case of a failed response we need to warn the user.
|
||||
*
|
||||
*/
|
||||
beforeMount() {
|
||||
const pipelinesService = new gl.commits.pipelines.PipelinesService(this.endpoint);
|
||||
eventHub.$on('refreshPipelines', this.fetchPipelines);
|
||||
},
|
||||
|
||||
beforeUpdate() {
|
||||
if (this.state.pipelines.length && this.$children) {
|
||||
this.store.startTimeAgoLoops.call(this, Vue);
|
||||
}
|
||||
},
|
||||
|
||||
beforeDestroyed() {
|
||||
eventHub.$off('refreshPipelines');
|
||||
},
|
||||
|
||||
methods: {
|
||||
fetchPipelines() {
|
||||
this.isLoading = true;
|
||||
return pipelinesService.all()
|
||||
return this.service.getPipelines()
|
||||
.then(response => response.json())
|
||||
.then((json) => {
|
||||
// depending of the endpoint the response can either bring a `pipelines` key or not.
|
||||
|
@ -71,34 +81,30 @@ const PipelineStore = require('./pipelines_store');
|
|||
})
|
||||
.catch(() => {
|
||||
this.isLoading = false;
|
||||
new Flash('An error occurred while fetching the pipelines, please reload the page again.', 'alert');
|
||||
new Flash('An error occurred while fetching the pipelines, please reload the page again.');
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
beforeUpdate() {
|
||||
if (this.state.pipelines.length && this.$children) {
|
||||
PipelineStore.startTimeAgoLoops.call(this, Vue);
|
||||
}
|
||||
},
|
||||
|
||||
template: `
|
||||
<div class="pipelines">
|
||||
<div class="realtime-loading" v-if="isLoading">
|
||||
<i class="fa fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
|
||||
<div class="blank-state blank-state-no-icon"
|
||||
v-if="!isLoading && state.pipelines.length === 0">
|
||||
<h2 class="blank-state-title js-blank-state-title">
|
||||
No pipelines to show
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="table-holder pipelines"
|
||||
v-if="!isLoading && state.pipelines.length > 0">
|
||||
<pipelines-table-component :pipelines="state.pipelines"/>
|
||||
</div>
|
||||
template: `
|
||||
<div class="pipelines">
|
||||
<div class="realtime-loading" v-if="isLoading">
|
||||
<i class="fa fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})();
|
||||
|
||||
<div class="blank-state blank-state-no-icon"
|
||||
v-if="!isLoading && state.pipelines.length === 0">
|
||||
<h2 class="blank-state-title js-blank-state-title">
|
||||
No pipelines to show
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="table-holder pipelines"
|
||||
v-if="!isLoading && state.pipelines.length > 0">
|
||||
<pipelines-table-component
|
||||
:pipelines="state.pipelines"
|
||||
:service="service" />
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
|
|
@ -1,21 +1,18 @@
|
|||
/* eslint-disable no-param-reassign, no-new */
|
||||
/* eslint-disable no-new */
|
||||
/* global Flash */
|
||||
import Vue from 'vue';
|
||||
import EnvironmentsService from '../services/environments_service';
|
||||
import EnvironmentTable from './environments_table';
|
||||
import EnvironmentsStore from '../stores/environments_store';
|
||||
import TablePaginationComponent from '../../vue_shared/components/table_pagination';
|
||||
import '../../lib/utils/common_utils';
|
||||
import eventHub from '../event_hub';
|
||||
|
||||
const Vue = window.Vue = require('vue');
|
||||
window.Vue.use(require('vue-resource'));
|
||||
require('../../vue_shared/components/table_pagination');
|
||||
require('../../lib/utils/common_utils');
|
||||
require('../../vue_shared/vue_resource_interceptor');
|
||||
|
||||
export default Vue.component('environment-component', {
|
||||
|
||||
components: {
|
||||
'environment-table': EnvironmentTable,
|
||||
'table-pagination': gl.VueGlPagination,
|
||||
'table-pagination': TablePaginationComponent,
|
||||
},
|
||||
|
||||
data() {
|
||||
|
@ -59,7 +56,6 @@ export default Vue.component('environment-component', {
|
|||
canCreateEnvironmentParsed() {
|
||||
return gl.utils.convertPermissionToBoolean(this.canCreateEnvironment);
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,24 +1,22 @@
|
|||
import Timeago from 'timeago.js';
|
||||
import '../../lib/utils/text_utility';
|
||||
import ActionsComponent from './environment_actions';
|
||||
import ExternalUrlComponent from './environment_external_url';
|
||||
import StopComponent from './environment_stop';
|
||||
import RollbackComponent from './environment_rollback';
|
||||
import TerminalButtonComponent from './environment_terminal_button';
|
||||
import '../../lib/utils/text_utility';
|
||||
import '../../vue_shared/components/commit';
|
||||
import CommitComponent from '../../vue_shared/components/commit';
|
||||
|
||||
/**
|
||||
* Envrionment Item Component
|
||||
*
|
||||
* Renders a table row for each environment.
|
||||
*/
|
||||
|
||||
const timeagoInstance = new Timeago();
|
||||
|
||||
export default {
|
||||
|
||||
components: {
|
||||
'commit-component': gl.CommitComponent,
|
||||
'commit-component': CommitComponent,
|
||||
'actions-component': ActionsComponent,
|
||||
'external-url-component': ExternalUrlComponent,
|
||||
'stop-component': StopComponent,
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
/**
|
||||
* Render environments table.
|
||||
*/
|
||||
import EnvironmentItem from './environment_item';
|
||||
import EnvironmentTableRowComponent from './environment_item';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
'environment-item': EnvironmentItem,
|
||||
'environment-item': EnvironmentTableRowComponent,
|
||||
},
|
||||
|
||||
props: {
|
||||
|
|
|
@ -1,20 +1,17 @@
|
|||
/* eslint-disable no-param-reassign, no-new */
|
||||
/* eslint-disable no-new */
|
||||
/* global Flash */
|
||||
import Vue from 'vue';
|
||||
import EnvironmentsService from '../services/environments_service';
|
||||
import EnvironmentTable from '../components/environments_table';
|
||||
import EnvironmentsStore from '../stores/environments_store';
|
||||
|
||||
const Vue = window.Vue = require('vue');
|
||||
window.Vue.use(require('vue-resource'));
|
||||
require('../../vue_shared/components/table_pagination');
|
||||
require('../../lib/utils/common_utils');
|
||||
require('../../vue_shared/vue_resource_interceptor');
|
||||
import TablePaginationComponent from '../../vue_shared/components/table_pagination';
|
||||
import '../../lib/utils/common_utils';
|
||||
import '../../vue_shared/vue_resource_interceptor';
|
||||
|
||||
export default Vue.component('environment-folder-view', {
|
||||
|
||||
components: {
|
||||
'environment-table': EnvironmentTable,
|
||||
'table-pagination': gl.VueGlPagination,
|
||||
'table-pagination': TablePaginationComponent,
|
||||
},
|
||||
|
||||
data() {
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
/* eslint-disable class-methods-use-this */
|
||||
import Vue from 'vue';
|
||||
import VueResource from 'vue-resource';
|
||||
|
||||
Vue.use(VueResource);
|
||||
|
||||
export default class EnvironmentsService {
|
||||
constructor(endpoint) {
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import '~/lib/utils/common_utils';
|
||||
|
||||
/**
|
||||
* Environments Store.
|
||||
*
|
||||
|
|
|
@ -176,7 +176,7 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
|
|||
_this.opts.ci_sha = data.sha;
|
||||
_this.updateCommitUrls(data.sha);
|
||||
}
|
||||
if (showNotification) {
|
||||
if (showNotification && data.status) {
|
||||
status = _this.ciLabelForStatus(data.status);
|
||||
if (status === "preparing") {
|
||||
title = _this.opts.ci_title.preparing;
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
/* eslint-disable no-new, no-alert */
|
||||
/* global Flash */
|
||||
import '~/flash';
|
||||
import eventHub from '../event_hub';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
endpoint: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
service: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
cssClass: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
confirmActionMessage: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
iconClass() {
|
||||
return `fa fa-${this.icon}`;
|
||||
},
|
||||
|
||||
buttonClass() {
|
||||
return `btn has-tooltip ${this.cssClass}`;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onClick() {
|
||||
if (this.confirmActionMessage && confirm(this.confirmActionMessage)) {
|
||||
this.makeRequest();
|
||||
} else if (!this.confirmActionMessage) {
|
||||
this.makeRequest();
|
||||
}
|
||||
},
|
||||
|
||||
makeRequest() {
|
||||
this.isLoading = true;
|
||||
|
||||
this.service.postAction(this.endpoint)
|
||||
.then(() => {
|
||||
this.isLoading = false;
|
||||
eventHub.$emit('refreshPipelines');
|
||||
})
|
||||
.catch(() => {
|
||||
this.isLoading = false;
|
||||
new Flash('An error occured while making the request.');
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
@click="onClick"
|
||||
:class="buttonClass"
|
||||
:title="title"
|
||||
:aria-label="title"
|
||||
data-placement="top"
|
||||
:disabled="isLoading">
|
||||
<i :class="iconClass" aria-hidden="true"/>
|
||||
<i class="fa fa-spinner fa-spin" aria-hidden="true" v-if="isLoading" />
|
||||
</button>
|
||||
`,
|
||||
};
|
|
@ -0,0 +1,56 @@
|
|||
export default {
|
||||
props: [
|
||||
'pipeline',
|
||||
],
|
||||
computed: {
|
||||
user() {
|
||||
return !!this.pipeline.user;
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<td>
|
||||
<a
|
||||
:href="pipeline.path"
|
||||
class="js-pipeline-url-link">
|
||||
<span class="pipeline-id">#{{pipeline.id}}</span>
|
||||
</a>
|
||||
<span>by</span>
|
||||
<a
|
||||
class="js-pipeline-url-user"
|
||||
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="js-pipeline-url-api api monospace">
|
||||
API
|
||||
</span>
|
||||
<span
|
||||
v-if="pipeline.flags.latest"
|
||||
class="js-pipeline-url-lastest 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="js-pipeline-url-yaml 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="js-pipeline-url-stuck label label-warning">
|
||||
stuck
|
||||
</span>
|
||||
</td>
|
||||
`,
|
||||
};
|
|
@ -0,0 +1,71 @@
|
|||
/* eslint-disable no-new */
|
||||
/* global Flash */
|
||||
import '~/flash';
|
||||
import playIconSvg from 'icons/_icon_play.svg';
|
||||
import eventHub from '../event_hub';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
actions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
|
||||
service: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
playIconSvg,
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
onClickAction(endpoint) {
|
||||
this.isLoading = true;
|
||||
|
||||
this.service.postAction(endpoint)
|
||||
.then(() => {
|
||||
this.isLoading = false;
|
||||
eventHub.$emit('refreshPipelines');
|
||||
})
|
||||
.catch(() => {
|
||||
this.isLoading = false;
|
||||
new Flash('An error occured while making the request.');
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
template: `
|
||||
<div class="btn-group" v-if="actions">
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-toggle btn btn-default has-tooltip js-pipeline-dropdown-manual-actions"
|
||||
title="Manual job"
|
||||
data-toggle="dropdown"
|
||||
data-placement="top"
|
||||
aria-label="Manual job"
|
||||
:disabled="isLoading">
|
||||
${playIconSvg}
|
||||
<i class="fa fa-caret-down" aria-hidden="true"></i>
|
||||
<i v-if="isLoading" class="fa fa-spinner fa-spin" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<ul class="dropdown-menu dropdown-menu-align-right">
|
||||
<li v-for="action in actions">
|
||||
<button
|
||||
type="button"
|
||||
class="js-pipeline-action-link no-btn"
|
||||
@click="onClickAction(action.path)">
|
||||
${playIconSvg}
|
||||
<span>{{action.name}}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
};
|
|
@ -0,0 +1,32 @@
|
|||
export default {
|
||||
props: {
|
||||
artifacts: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
template: `
|
||||
<div class="btn-group" role="group">
|
||||
<button
|
||||
class="dropdown-toggle btn btn-default build-artifacts has-tooltip js-pipeline-dropdown-download"
|
||||
title="Artifacts"
|
||||
data-placement="top"
|
||||
data-toggle="dropdown"
|
||||
aria-label="Artifacts">
|
||||
<i class="fa fa-download" aria-hidden="true"></i>
|
||||
<i class="fa fa-caret-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-align-right">
|
||||
<li v-for="artifact in artifacts">
|
||||
<a
|
||||
rel="nofollow"
|
||||
:href="artifact.path">
|
||||
<i class="fa fa-download" aria-hidden="true"></i>
|
||||
<span>Download {{artifact.name}} artifacts</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
};
|
116
app/assets/javascripts/vue_pipelines_index/components/stage.js
Normal file
|
@ -0,0 +1,116 @@
|
|||
/* global Flash */
|
||||
import canceledSvg from 'icons/_icon_status_canceled_borderless.svg';
|
||||
import createdSvg from 'icons/_icon_status_created_borderless.svg';
|
||||
import failedSvg from 'icons/_icon_status_failed_borderless.svg';
|
||||
import manualSvg from 'icons/_icon_status_manual_borderless.svg';
|
||||
import pendingSvg from 'icons/_icon_status_pending_borderless.svg';
|
||||
import runningSvg from 'icons/_icon_status_running_borderless.svg';
|
||||
import skippedSvg from 'icons/_icon_status_skipped_borderless.svg';
|
||||
import successSvg from 'icons/_icon_status_success_borderless.svg';
|
||||
import warningSvg from 'icons/_icon_status_warning_borderless.svg';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
const svgsDictionary = {
|
||||
icon_status_canceled: canceledSvg,
|
||||
icon_status_created: createdSvg,
|
||||
icon_status_failed: failedSvg,
|
||||
icon_status_manual: manualSvg,
|
||||
icon_status_pending: pendingSvg,
|
||||
icon_status_running: runningSvg,
|
||||
icon_status_skipped: skippedSvg,
|
||||
icon_status_success: successSvg,
|
||||
icon_status_warning: warningSvg,
|
||||
};
|
||||
|
||||
return {
|
||||
builds: '',
|
||||
spinner: '<span class="fa fa-spinner fa-spin"></span>',
|
||||
svg: svgsDictionary[this.stage.status.icon],
|
||||
};
|
||||
},
|
||||
|
||||
props: {
|
||||
stage: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
updated() {
|
||||
if (this.builds) {
|
||||
this.stopDropdownClickPropagation();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
fetchBuilds(e) {
|
||||
const ariaExpanded = e.currentTarget.attributes['aria-expanded'];
|
||||
|
||||
if (ariaExpanded && (ariaExpanded.textContent === 'true')) return null;
|
||||
|
||||
return this.$http.get(this.stage.dropdown_path)
|
||||
.then((response) => {
|
||||
this.builds = JSON.parse(response.body).html;
|
||||
}, () => {
|
||||
const flash = new Flash('Something went wrong on our end.');
|
||||
return flash;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* When the user right clicks or cmd/ctrl + click in the job name
|
||||
* the dropdown should not be closed and the link should open in another tab,
|
||||
* so we stop propagation of the click event inside the dropdown.
|
||||
*
|
||||
* Since this component is rendered multiple times per page we need to guarantee we only
|
||||
* target the click event of this component.
|
||||
*/
|
||||
stopDropdownClickPropagation() {
|
||||
$(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item')).on('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
buildsOrSpinner() {
|
||||
return this.builds ? this.builds : this.spinner;
|
||||
},
|
||||
dropdownClass() {
|
||||
if (this.builds) 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}`;
|
||||
},
|
||||
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($event)"
|
||||
:class="triggerButtonClass"
|
||||
:title="stage.title"
|
||||
data-placement="top"
|
||||
data-toggle="dropdown"
|
||||
type="button"
|
||||
:aria-label="stage.title">
|
||||
<span v-html="svg" aria-hidden="true"></span>
|
||||
<i class="fa fa-caret-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container">
|
||||
<div class="arrow-up" aria-hidden="true"></div>
|
||||
<div
|
||||
:class="dropdownClass"
|
||||
class="js-builds-dropdown-list scrollable-menu"
|
||||
v-html="buildsOrSpinner">
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
};
|
|
@ -0,0 +1,60 @@
|
|||
import canceledSvg from 'icons/_icon_status_canceled.svg';
|
||||
import createdSvg from 'icons/_icon_status_created.svg';
|
||||
import failedSvg from 'icons/_icon_status_failed.svg';
|
||||
import manualSvg from 'icons/_icon_status_manual.svg';
|
||||
import pendingSvg from 'icons/_icon_status_pending.svg';
|
||||
import runningSvg from 'icons/_icon_status_running.svg';
|
||||
import skippedSvg from 'icons/_icon_status_skipped.svg';
|
||||
import successSvg from 'icons/_icon_status_success.svg';
|
||||
import warningSvg from 'icons/_icon_status_warning.svg';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
pipeline: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
const svgsDictionary = {
|
||||
icon_status_canceled: canceledSvg,
|
||||
icon_status_created: createdSvg,
|
||||
icon_status_failed: failedSvg,
|
||||
icon_status_manual: manualSvg,
|
||||
icon_status_pending: pendingSvg,
|
||||
icon_status_running: runningSvg,
|
||||
icon_status_skipped: skippedSvg,
|
||||
icon_status_success: successSvg,
|
||||
icon_status_warning: warningSvg,
|
||||
};
|
||||
|
||||
return {
|
||||
svg: svgsDictionary[this.pipeline.details.status.icon],
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
cssClasses() {
|
||||
return `ci-status ci-${this.pipeline.details.status.group}`;
|
||||
},
|
||||
|
||||
detailsPath() {
|
||||
const { status } = this.pipeline.details;
|
||||
return status.has_details ? status.details_path : false;
|
||||
},
|
||||
|
||||
content() {
|
||||
return `${this.svg} ${this.pipeline.details.status.text}`;
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<td class="commit-link">
|
||||
<a
|
||||
:class="cssClasses"
|
||||
:href="detailsPath"
|
||||
v-html="content">
|
||||
</a>
|
||||
</td>
|
||||
`,
|
||||
};
|
|
@ -0,0 +1,71 @@
|
|||
import iconTimerSvg from 'icons/_icon_timer.svg';
|
||||
import '../../lib/utils/datetime_utility';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
currentTime: new Date(),
|
||||
iconTimerSvg,
|
||||
};
|
||||
},
|
||||
props: ['pipeline'],
|
||||
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 class="pipelines-time-ago">
|
||||
<p class="duration" v-if='duration'>
|
||||
<span v-html="iconTimerSvg"></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>
|
||||
`,
|
||||
};
|
3
app/assets/javascripts/vue_pipelines_index/event_hub.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
export default new Vue();
|
|
@ -1,23 +1,28 @@
|
|||
/* eslint-disable no-param-reassign */
|
||||
/* global Vue, VueResource, gl */
|
||||
window.Vue = require('vue');
|
||||
import PipelinesStore from './stores/pipelines_store';
|
||||
import PipelinesComponent from './pipelines';
|
||||
import '../vue_shared/vue_resource_interceptor';
|
||||
|
||||
const Vue = window.Vue = require('vue');
|
||||
window.Vue.use(require('vue-resource'));
|
||||
require('../lib/utils/common_utils');
|
||||
require('../vue_shared/vue_resource_interceptor');
|
||||
require('./pipelines');
|
||||
|
||||
$(() => new Vue({
|
||||
el: document.querySelector('#pipelines-list-vue'),
|
||||
|
||||
data() {
|
||||
const project = document.querySelector('.pipelines');
|
||||
const store = new PipelinesStore();
|
||||
|
||||
return {
|
||||
store: new gl.PipelineStore(),
|
||||
store,
|
||||
endpoint: project.dataset.url,
|
||||
};
|
||||
},
|
||||
components: {
|
||||
'vue-pipelines': gl.VuePipelines,
|
||||
'vue-pipelines': PipelinesComponent,
|
||||
},
|
||||
template: `
|
||||
<vue-pipelines :store="store"/>
|
||||
<vue-pipelines
|
||||
:endpoint="endpoint"
|
||||
:store="store" />
|
||||
`,
|
||||
}));
|
||||
|
|
|
@ -1,123 +0,0 @@
|
|||
/* global Vue, Flash, gl */
|
||||
/* eslint-disable no-param-reassign, no-alert */
|
||||
const playIconSvg = require('icons/_icon_play.svg');
|
||||
|
||||
((gl) => {
|
||||
gl.VuePipelineActions = Vue.extend({
|
||||
props: ['pipeline'],
|
||||
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`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Shows a dialog when the user clicks in the cancel button.
|
||||
* We need to prevent the default behavior and stop propagation because the
|
||||
* link relies on UJS.
|
||||
*
|
||||
* @param {Event} event
|
||||
*/
|
||||
confirmAction(event) {
|
||||
if (!confirm('Are you sure you want to cancel this pipeline?')) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return { playIconSvg };
|
||||
},
|
||||
|
||||
template: `
|
||||
<td class="pipeline-actions">
|
||||
<div class="pull-right">
|
||||
<div class="btn-group">
|
||||
<div class="btn-group" v-if="actions">
|
||||
<button
|
||||
class="dropdown-toggle btn btn-default has-tooltip js-pipeline-dropdown-manual-actions"
|
||||
data-toggle="dropdown"
|
||||
title="Manual job"
|
||||
data-placement="top"
|
||||
data-container="body"
|
||||
aria-label="Manual job">
|
||||
<span v-html="playIconSvg" aria-hidden="true"></span>
|
||||
<i class="fa fa-caret-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<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" >
|
||||
<span v-html="playIconSvg" aria-hidden="true"></span>
|
||||
<span>{{action.name}}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="btn-group" v-if="artifacts">
|
||||
<button
|
||||
class="dropdown-toggle btn btn-default build-artifacts has-tooltip js-pipeline-dropdown-download"
|
||||
title="Artifacts"
|
||||
data-placement="top"
|
||||
data-container="body"
|
||||
data-toggle="dropdown"
|
||||
aria-label="Artifacts">
|
||||
<i class="fa fa-download" aria-hidden="true"></i>
|
||||
<i class="fa fa-caret-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<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" aria-hidden="true"></i>
|
||||
<span>{{download(artifact.name)}}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="btn-group" v-if="pipeline.flags.retryable">
|
||||
<a
|
||||
class="btn btn-default btn-retry has-tooltip"
|
||||
title="Retry"
|
||||
rel="nofollow"
|
||||
data-method="post"
|
||||
data-placement="top"
|
||||
data-container="body"
|
||||
data-toggle="dropdown"
|
||||
:href='pipeline.retry_path'
|
||||
aria-label="Retry">
|
||||
<i class="fa fa-repeat" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="btn-group" v-if="pipeline.flags.cancelable">
|
||||
<a
|
||||
class="btn btn-remove has-tooltip"
|
||||
title="Cancel"
|
||||
rel="nofollow"
|
||||
data-method="post"
|
||||
data-placement="top"
|
||||
data-container="body"
|
||||
data-toggle="dropdown"
|
||||
:href='pipeline.cancel_path'
|
||||
aria-label="Cancel">
|
||||
<i class="fa fa-remove" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
`,
|
||||
});
|
||||
})(window.gl || (window.gl = {}));
|
|
@ -1,63 +0,0 @@
|
|||
/* 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 = {}));
|
|
@ -1,266 +1,290 @@
|
|||
/* global Vue, gl */
|
||||
/* eslint-disable no-param-reassign */
|
||||
/* global Flash */
|
||||
/* eslint-disable no-new */
|
||||
import Vue from 'vue';
|
||||
import '~/flash';
|
||||
import pipelinesEmptyStateSVG from 'empty_states/icons/_pipelines_empty.svg';
|
||||
import pipelinesErrorStateSVG from 'empty_states/icons/_pipelines_failed.svg';
|
||||
import PipelinesService from './services/pipelines_service';
|
||||
import eventHub from './event_hub';
|
||||
import PipelinesTableComponent from '../vue_shared/components/pipelines_table';
|
||||
import TablePaginationComponent from '../vue_shared/components/table_pagination';
|
||||
|
||||
window.Vue = require('vue');
|
||||
require('../vue_shared/components/table_pagination');
|
||||
require('./store');
|
||||
require('../vue_shared/components/pipelines_table');
|
||||
const CommitPipelinesStoreWithTimeAgo = require('../commit/pipelines/pipelines_store');
|
||||
export default {
|
||||
props: {
|
||||
endpoint: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
((gl) => {
|
||||
gl.VuePipelines = Vue.extend({
|
||||
components: {
|
||||
'gl-pagination': TablePaginationComponent,
|
||||
'pipelines-table-component': PipelinesTableComponent,
|
||||
},
|
||||
|
||||
components: {
|
||||
'gl-pagination': gl.VueGlPagination,
|
||||
'pipelines-table-component': gl.pipelines.PipelinesTableComponent,
|
||||
computed: {
|
||||
canCreatePipelineParsed() {
|
||||
return gl.utils.convertPermissionToBoolean(this.canCreatePipeline);
|
||||
},
|
||||
|
||||
data() {
|
||||
const pipelinesData = document.querySelector('#pipelines-list-vue').dataset;
|
||||
|
||||
return {
|
||||
...pipelinesData,
|
||||
pipelines: [],
|
||||
apiScope: 'all',
|
||||
pageInfo: {},
|
||||
pagenum: 1,
|
||||
count: {
|
||||
all: 0,
|
||||
pending: 0,
|
||||
running: 0,
|
||||
finished: 0,
|
||||
},
|
||||
pageRequest: false,
|
||||
hasError: false,
|
||||
pipelinesEmptyStateSVG,
|
||||
pipelinesErrorStateSVG,
|
||||
};
|
||||
},
|
||||
props: ['scope', 'store'],
|
||||
created() {
|
||||
const pagenum = gl.utils.getParameterByName('page');
|
||||
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.endpoint, this.apiScope);
|
||||
scope() {
|
||||
return gl.utils.getParameterByName('scope');
|
||||
},
|
||||
|
||||
beforeUpdate() {
|
||||
if (this.pipelines.length && this.$children) {
|
||||
CommitPipelinesStoreWithTimeAgo.startTimeAgoLoops.call(this, Vue);
|
||||
}
|
||||
shouldRenderErrorState() {
|
||||
return this.hasError && !this.pageRequest;
|
||||
},
|
||||
|
||||
computed: {
|
||||
canCreatePipelineParsed() {
|
||||
return gl.utils.convertPermissionToBoolean(this.canCreatePipeline);
|
||||
},
|
||||
|
||||
scope() {
|
||||
return gl.utils.getParameterByName('scope');
|
||||
},
|
||||
|
||||
shouldRenderErrorState() {
|
||||
return this.hasError && !this.pageRequest;
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* The empty state should only be rendered when the request is made to fetch all pipelines
|
||||
* and none is returned.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
shouldRenderEmptyState() {
|
||||
return !this.hasError &&
|
||||
!this.pageRequest && (
|
||||
!this.pipelines.length && (this.scope === 'all' || this.scope === null)
|
||||
);
|
||||
},
|
||||
|
||||
shouldRenderTable() {
|
||||
return !this.hasError &&
|
||||
!this.pageRequest && this.pipelines.length;
|
||||
},
|
||||
|
||||
/**
|
||||
* Header tabs should only be rendered when we receive an error or a successfull response with
|
||||
* pipelines.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
shouldRenderTabs() {
|
||||
return !this.pageRequest && !this.hasError && this.pipelines.length;
|
||||
},
|
||||
|
||||
/**
|
||||
* Pagination should only be rendered when there is more than one page.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
shouldRenderPagination() {
|
||||
return !this.pageRequest &&
|
||||
this.pipelines.length &&
|
||||
this.pageInfo.total > this.pageInfo.perPage;
|
||||
},
|
||||
/**
|
||||
* The empty state should only be rendered when the request is made to fetch all pipelines
|
||||
* and none is returned.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
shouldRenderEmptyState() {
|
||||
return !this.hasError &&
|
||||
!this.pageRequest && (
|
||||
!this.pipelines.length && (this.scope === 'all' || this.scope === null)
|
||||
);
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Will change the page number and update the URL.
|
||||
*
|
||||
* @param {Number} pageNumber desired page to go to.
|
||||
*/
|
||||
change(pageNumber) {
|
||||
const param = gl.utils.setParamInURL('page', pageNumber);
|
||||
|
||||
gl.utils.visitUrl(param);
|
||||
return param;
|
||||
},
|
||||
shouldRenderTable() {
|
||||
return !this.hasError &&
|
||||
!this.pageRequest && this.pipelines.length;
|
||||
},
|
||||
template: `
|
||||
<div :class="cssClass">
|
||||
<div class="top-area" v-if="!shouldRenderEmptyState">
|
||||
<ul
|
||||
class="nav-links">
|
||||
|
||||
<li :class="{ 'active': scope === null || scope === 'all'}">
|
||||
<a :href="allPath">
|
||||
All
|
||||
</a>
|
||||
<span class="badge js-totalbuilds-count">
|
||||
{{count.all}}
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
class="js-pipelines-tab-pending"
|
||||
:class="{ 'active': scope === 'pending'}">
|
||||
<a :href="pendingPath">
|
||||
Pending
|
||||
</a>
|
||||
/**
|
||||
* Header tabs should only be rendered when we receive an error or a successfull response with
|
||||
* pipelines.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
shouldRenderTabs() {
|
||||
return !this.pageRequest && !this.hasError && this.pipelines.length;
|
||||
},
|
||||
|
||||
<span class="badge">
|
||||
{{count.pending}}
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
class="js-pipelines-tab-running"
|
||||
:class="{ 'active': scope === 'running'}">
|
||||
/**
|
||||
* Pagination should only be rendered when there is more than one page.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
shouldRenderPagination() {
|
||||
return !this.pageRequest &&
|
||||
this.pipelines.length &&
|
||||
this.pageInfo.total > this.pageInfo.perPage;
|
||||
},
|
||||
},
|
||||
|
||||
<a :href="runningPath">
|
||||
Running
|
||||
</a>
|
||||
data() {
|
||||
const pipelinesData = document.querySelector('#pipelines-list-vue').dataset;
|
||||
|
||||
<span class="badge">
|
||||
{{count.running}}
|
||||
</span>
|
||||
</li>
|
||||
return {
|
||||
...pipelinesData,
|
||||
state: this.store.state,
|
||||
apiScope: 'all',
|
||||
pagenum: 1,
|
||||
pageRequest: false,
|
||||
hasError: false,
|
||||
pipelinesEmptyStateSVG,
|
||||
pipelinesErrorStateSVG,
|
||||
};
|
||||
},
|
||||
|
||||
<li
|
||||
class="js-pipelines-tab-finished"
|
||||
:class="{ 'active': scope === 'finished'}">
|
||||
created() {
|
||||
this.service = new PipelinesService(this.endpoint);
|
||||
|
||||
<a :href="finishedPath">
|
||||
Finished
|
||||
</a>
|
||||
<span class="badge">
|
||||
{{count.finished}}
|
||||
</span>
|
||||
</li>
|
||||
this.fetchPipelines();
|
||||
|
||||
<li
|
||||
class="js-pipelines-tab-branches"
|
||||
:class="{ 'active': scope === 'branches'}">
|
||||
<a :href="branchesPath">Branches</a>
|
||||
</li>
|
||||
eventHub.$on('refreshPipelines', this.fetchPipelines);
|
||||
},
|
||||
|
||||
<li
|
||||
class="js-pipelines-tab-tags"
|
||||
:class="{ 'active': scope === 'tags'}">
|
||||
<a :href="tagsPath">Tags</a>
|
||||
</li>
|
||||
</ul>
|
||||
beforeUpdate() {
|
||||
if (this.state.pipelines.length && this.$children) {
|
||||
this.store.startTimeAgoLoops.call(this, Vue);
|
||||
}
|
||||
},
|
||||
|
||||
<div class="nav-controls">
|
||||
<a
|
||||
v-if="canCreatePipelineParsed"
|
||||
:href="newPipelinePath"
|
||||
class="btn btn-create">
|
||||
Run Pipeline
|
||||
beforeDestroyed() {
|
||||
eventHub.$off('refreshPipelines');
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Will change the page number and update the URL.
|
||||
*
|
||||
* @param {Number} pageNumber desired page to go to.
|
||||
*/
|
||||
change(pageNumber) {
|
||||
const param = gl.utils.setParamInURL('page', pageNumber);
|
||||
|
||||
gl.utils.visitUrl(param);
|
||||
return param;
|
||||
},
|
||||
|
||||
fetchPipelines() {
|
||||
const pageNumber = gl.utils.getParameterByName('page') || this.pagenum;
|
||||
const scope = gl.utils.getParameterByName('scope') || this.apiScope;
|
||||
|
||||
this.pageRequest = true;
|
||||
return this.service.getPipelines(scope, pageNumber)
|
||||
.then(resp => ({
|
||||
headers: resp.headers,
|
||||
body: resp.json(),
|
||||
}))
|
||||
.then((response) => {
|
||||
this.store.storeCount(response.body.count);
|
||||
this.store.storePipelines(response.body.pipelines);
|
||||
this.store.storePagination(response.headers);
|
||||
})
|
||||
.then(() => {
|
||||
this.pageRequest = false;
|
||||
})
|
||||
.catch(() => {
|
||||
this.pageRequest = false;
|
||||
new Flash('An error occurred while fetching the pipelines, please reload the page again.');
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
template: `
|
||||
<div :class="cssClass">
|
||||
<div class="top-area" v-if="!shouldRenderEmptyState">
|
||||
<ul
|
||||
class="nav-links">
|
||||
|
||||
<li :class="{ 'active': scope === null || scope === 'all'}">
|
||||
<a :href="allPath">
|
||||
All
|
||||
</a>
|
||||
<span class="badge js-totalbuilds-count">
|
||||
{{count.all}}
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
class="js-pipelines-tab-pending"
|
||||
:class="{ 'active': scope === 'pending'}">
|
||||
<a :href="pendingPath">
|
||||
Pending
|
||||
</a>
|
||||
|
||||
<a
|
||||
v-if="!hasCi"
|
||||
:href="helpPagePath"
|
||||
class="btn btn-info">
|
||||
Get started with Pipelines
|
||||
<span class="badge">
|
||||
{{count.pending}}
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
class="js-pipelines-tab-running"
|
||||
:class="{ 'active': scope === 'running'}">
|
||||
|
||||
<a :href="runningPath">
|
||||
Running
|
||||
</a>
|
||||
|
||||
<a
|
||||
:href="ciLintPath"
|
||||
class="btn btn-default">
|
||||
CI Lint
|
||||
<span class="badge">
|
||||
{{count.running}}
|
||||
</span>
|
||||
</li>
|
||||
|
||||
<li
|
||||
class="js-pipelines-tab-finished"
|
||||
:class="{ 'active': scope === 'finished'}">
|
||||
|
||||
<a :href="finishedPath">
|
||||
Finished
|
||||
</a>
|
||||
</div>
|
||||
<span class="badge">
|
||||
{{count.finished}}
|
||||
</span>
|
||||
</li>
|
||||
|
||||
<li
|
||||
class="js-pipelines-tab-branches"
|
||||
:class="{ 'active': scope === 'branches'}">
|
||||
<a :href="branchesPath">Branches</a>
|
||||
</li>
|
||||
|
||||
<li
|
||||
class="js-pipelines-tab-tags"
|
||||
:class="{ 'active': scope === 'tags'}">
|
||||
<a :href="tagsPath">Tags</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="nav-controls">
|
||||
<a
|
||||
v-if="canCreatePipelineParsed"
|
||||
:href="newPipelinePath"
|
||||
class="btn btn-create">
|
||||
Run Pipeline
|
||||
</a>
|
||||
|
||||
<a
|
||||
v-if="!hasCi"
|
||||
:href="helpPagePath"
|
||||
class="btn btn-info">
|
||||
Get started with Pipelines
|
||||
</a>
|
||||
|
||||
<a
|
||||
:href="ciLintPath"
|
||||
class="btn btn-default">
|
||||
CI Lint
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="pipelines realtime-loading"
|
||||
v-if="pageRequest">
|
||||
<i class="fa fa-spinner fa-spin" aria-hidden="true"></i>
|
||||
</div>
|
||||
|
||||
<div v-if="shouldRenderEmptyState"
|
||||
class="row empty-state">
|
||||
<div class="col-xs-12 pull-right">
|
||||
<div class="svg-content">
|
||||
${pipelinesEmptyStateSVG}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-12 center">
|
||||
<div class="text-content">
|
||||
<h4>Build with confidence</h4>
|
||||
<p>
|
||||
Continous Integration can help catch bugs by running your tests automatically,
|
||||
while Continuous Deployment can help you deliver code to your product environment.
|
||||
<a :href="helpPagePath" class="btn btn-info">
|
||||
Get started with Pipelines
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="shouldRenderErrorState"
|
||||
class="row empty-state">
|
||||
<div class="col-xs-12 pull-right">
|
||||
<div class="svg-content">
|
||||
${pipelinesErrorStateSVG}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-12 center">
|
||||
<div class="text-content">
|
||||
<h4>The API failed to fetch the pipelines.</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-holder"
|
||||
v-if="shouldRenderTable">
|
||||
<pipelines-table-component :pipelines='pipelines'/>
|
||||
</div>
|
||||
|
||||
<gl-pagination
|
||||
v-if="shouldRenderPagination"
|
||||
:pagenum="pagenum"
|
||||
:change="change"
|
||||
:count="count.all"
|
||||
:pageInfo="pageInfo"/>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})(window.gl || (window.gl = {}));
|
||||
|
||||
<div class="pipelines realtime-loading"
|
||||
v-if="pageRequest">
|
||||
<i class="fa fa-spinner fa-spin" aria-hidden="true"></i>
|
||||
</div>
|
||||
|
||||
<div v-if="shouldRenderEmptyState"
|
||||
class="row empty-state">
|
||||
<div class="col-xs-12 pull-right">
|
||||
<div class="svg-content">
|
||||
${pipelinesEmptyStateSVG}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-12 center">
|
||||
<div class="text-content">
|
||||
<h4>Build with confidence</h4>
|
||||
<p>
|
||||
Continous Integration can help catch bugs by running your tests automatically,
|
||||
while Continuous Deployment can help you deliver code to your product environment.
|
||||
<a :href="helpPagePath" class="btn btn-info">
|
||||
Get started with Pipelines
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="shouldRenderErrorState"
|
||||
class="row empty-state">
|
||||
<div class="col-xs-12 pull-right">
|
||||
<div class="svg-content">
|
||||
${pipelinesErrorStateSVG}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-12 center">
|
||||
<div class="text-content">
|
||||
<h4>The API failed to fetch the pipelines.</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-holder"
|
||||
v-if="shouldRenderTable">
|
||||
<pipelines-table-component :pipelines='pipelines'/>
|
||||
</div>
|
||||
|
||||
<gl-pagination
|
||||
v-if="shouldRenderPagination"
|
||||
:pagenum="pagenum"
|
||||
:change="change"
|
||||
:count="count.all"
|
||||
:pageInfo="pageInfo"/>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
/* eslint-disable class-methods-use-this */
|
||||
import Vue from 'vue';
|
||||
import VueResource from 'vue-resource';
|
||||
|
||||
Vue.use(VueResource);
|
||||
|
||||
export default class PipelinesService {
|
||||
|
||||
/**
|
||||
* Commits and merge request endpoints need to be requested with `.json`.
|
||||
*
|
||||
* The url provided to request the pipelines in the new merge request
|
||||
* page already has `.json`.
|
||||
*
|
||||
* @param {String} root
|
||||
*/
|
||||
constructor(root) {
|
||||
let endpoint;
|
||||
|
||||
if (root.indexOf('.json') === -1) {
|
||||
endpoint = `${root}.json`;
|
||||
} else {
|
||||
endpoint = root;
|
||||
}
|
||||
|
||||
this.pipelines = Vue.resource(endpoint);
|
||||
}
|
||||
|
||||
getPipelines(scope, page) {
|
||||
return this.pipelines.get({ scope, page });
|
||||
}
|
||||
|
||||
/**
|
||||
* Post request for all pipelines actions.
|
||||
* Endpoint content type needs to be:
|
||||
* `Content-Type:application/x-www-form-urlencoded`
|
||||
*
|
||||
* @param {String} endpoint
|
||||
* @return {Promise}
|
||||
*/
|
||||
postAction(endpoint) {
|
||||
return Vue.http.post(endpoint, {}, { emulateJSON: true });
|
||||
}
|
||||
}
|
|
@ -1,119 +0,0 @@
|
|||
/* global Vue, Flash, gl */
|
||||
/* eslint-disable no-param-reassign */
|
||||
import canceledSvg from 'icons/_icon_status_canceled_borderless.svg';
|
||||
import createdSvg from 'icons/_icon_status_created_borderless.svg';
|
||||
import failedSvg from 'icons/_icon_status_failed_borderless.svg';
|
||||
import manualSvg from 'icons/_icon_status_manual_borderless.svg';
|
||||
import pendingSvg from 'icons/_icon_status_pending_borderless.svg';
|
||||
import runningSvg from 'icons/_icon_status_running_borderless.svg';
|
||||
import skippedSvg from 'icons/_icon_status_skipped_borderless.svg';
|
||||
import successSvg from 'icons/_icon_status_success_borderless.svg';
|
||||
import warningSvg from 'icons/_icon_status_warning_borderless.svg';
|
||||
|
||||
((gl) => {
|
||||
gl.VueStage = Vue.extend({
|
||||
data() {
|
||||
const svgsDictionary = {
|
||||
icon_status_canceled: canceledSvg,
|
||||
icon_status_created: createdSvg,
|
||||
icon_status_failed: failedSvg,
|
||||
icon_status_manual: manualSvg,
|
||||
icon_status_pending: pendingSvg,
|
||||
icon_status_running: runningSvg,
|
||||
icon_status_skipped: skippedSvg,
|
||||
icon_status_success: successSvg,
|
||||
icon_status_warning: warningSvg,
|
||||
};
|
||||
|
||||
return {
|
||||
builds: '',
|
||||
spinner: '<span class="fa fa-spinner fa-spin"></span>',
|
||||
svg: svgsDictionary[this.stage.status.icon],
|
||||
};
|
||||
},
|
||||
|
||||
props: {
|
||||
stage: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
updated() {
|
||||
if (this.builds) {
|
||||
this.stopDropdownClickPropagation();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
fetchBuilds(e) {
|
||||
const areaExpanded = e.currentTarget.attributes['aria-expanded'];
|
||||
|
||||
if (areaExpanded && (areaExpanded.textContent === 'true')) return null;
|
||||
|
||||
return this.$http.get(this.stage.dropdown_path)
|
||||
.then((response) => {
|
||||
this.builds = JSON.parse(response.body).html;
|
||||
}, () => {
|
||||
const flash = new Flash('Something went wrong on our end.');
|
||||
return flash;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* When the user right clicks or cmd/ctrl + click in the job name
|
||||
* the dropdown should not be closed and the link should open in another tab,
|
||||
* so we stop propagation of the click event inside the dropdown.
|
||||
*
|
||||
* Since this component is rendered multiple times per page we need to guarantee we only
|
||||
* target the click event of this component.
|
||||
*/
|
||||
stopDropdownClickPropagation() {
|
||||
$(this.$el).on('click', '.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
buildsOrSpinner() {
|
||||
return this.builds ? this.builds : this.spinner;
|
||||
},
|
||||
dropdownClass() {
|
||||
if (this.builds) 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}`;
|
||||
},
|
||||
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($event)"
|
||||
:class="triggerButtonClass"
|
||||
:title="stage.title"
|
||||
data-placement="top"
|
||||
data-toggle="dropdown"
|
||||
type="button"
|
||||
:aria-label="stage.title">
|
||||
<span v-html="svg" aria-hidden="true"></span>
|
||||
<i class="fa fa-caret-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container">
|
||||
<div class="arrow-up" aria-hidden="true"></div>
|
||||
<div
|
||||
:class="dropdownClass"
|
||||
class="js-builds-dropdown-list scrollable-menu"
|
||||
v-html="buildsOrSpinner">
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})(window.gl || (window.gl = {}));
|
|
@ -1,64 +0,0 @@
|
|||
/* global Vue, gl */
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
import canceledSvg from 'icons/_icon_status_canceled.svg';
|
||||
import createdSvg from 'icons/_icon_status_created.svg';
|
||||
import failedSvg from 'icons/_icon_status_failed.svg';
|
||||
import manualSvg from 'icons/_icon_status_manual.svg';
|
||||
import pendingSvg from 'icons/_icon_status_pending.svg';
|
||||
import runningSvg from 'icons/_icon_status_running.svg';
|
||||
import skippedSvg from 'icons/_icon_status_skipped.svg';
|
||||
import successSvg from 'icons/_icon_status_success.svg';
|
||||
import warningSvg from 'icons/_icon_status_warning.svg';
|
||||
|
||||
((gl) => {
|
||||
gl.VueStatusScope = Vue.extend({
|
||||
props: [
|
||||
'pipeline',
|
||||
],
|
||||
|
||||
data() {
|
||||
const svgsDictionary = {
|
||||
icon_status_canceled: canceledSvg,
|
||||
icon_status_created: createdSvg,
|
||||
icon_status_failed: failedSvg,
|
||||
icon_status_manual: manualSvg,
|
||||
icon_status_pending: pendingSvg,
|
||||
icon_status_running: runningSvg,
|
||||
icon_status_skipped: skippedSvg,
|
||||
icon_status_success: successSvg,
|
||||
icon_status_warning: warningSvg,
|
||||
};
|
||||
|
||||
return {
|
||||
svg: svgsDictionary[this.pipeline.details.status.icon],
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
cssClasses() {
|
||||
const cssObject = { 'ci-status': true };
|
||||
cssObject[`ci-${this.pipeline.details.status.group}`] = true;
|
||||
return cssObject;
|
||||
},
|
||||
|
||||
detailsPath() {
|
||||
const { status } = this.pipeline.details;
|
||||
return status.has_details ? status.details_path : false;
|
||||
},
|
||||
|
||||
content() {
|
||||
return `${this.svg} ${this.pipeline.details.status.text}`;
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<td class="commit-link">
|
||||
<a
|
||||
:class="cssClasses"
|
||||
:href="detailsPath"
|
||||
v-html="content">
|
||||
</a>
|
||||
</td>
|
||||
`,
|
||||
});
|
||||
})(window.gl || (window.gl = {}));
|
|
@ -1,32 +0,0 @@
|
|||
/* global gl, Flash */
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
((gl) => {
|
||||
const pageValues = (headers) => {
|
||||
const normalized = gl.utils.normalizeHeaders(headers);
|
||||
const paginationInfo = gl.utils.parseIntPagination(normalized);
|
||||
return paginationInfo;
|
||||
};
|
||||
|
||||
gl.PipelineStore = class {
|
||||
fetchDataLoop(Vue, pageNum, url, apiScope) {
|
||||
this.pageRequest = true;
|
||||
|
||||
return 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);
|
||||
|
||||
this.pageRequest = false;
|
||||
}, () => {
|
||||
this.pageRequest = false;
|
||||
this.hasError = true;
|
||||
return new Flash('An error occurred while fetching the pipelines, please reload the page again.');
|
||||
});
|
||||
}
|
||||
};
|
||||
})(window.gl || (window.gl = {}));
|
|
@ -1,31 +1,46 @@
|
|||
/* eslint-disable no-underscore-dangle*/
|
||||
/**
|
||||
* Pipelines' Store for commits view.
|
||||
*
|
||||
* Used to store the Pipelines rendered in the commit view in the pipelines table.
|
||||
*/
|
||||
require('../../vue_realtime_listener');
|
||||
import '../../vue_realtime_listener';
|
||||
|
||||
class PipelinesStore {
|
||||
export default class PipelinesStore {
|
||||
constructor() {
|
||||
this.state = {};
|
||||
|
||||
this.state.pipelines = [];
|
||||
this.state.count = {};
|
||||
this.state.pageInfo = {};
|
||||
}
|
||||
|
||||
storePipelines(pipelines = []) {
|
||||
this.state.pipelines = pipelines;
|
||||
}
|
||||
|
||||
return pipelines;
|
||||
storeCount(count = {}) {
|
||||
this.state.count = count;
|
||||
}
|
||||
|
||||
storePagination(pagination = {}) {
|
||||
let paginationInfo;
|
||||
|
||||
if (Object.keys(pagination).length) {
|
||||
const normalizedHeaders = gl.utils.normalizeHeaders(pagination);
|
||||
paginationInfo = gl.utils.parseIntPagination(normalizedHeaders);
|
||||
} else {
|
||||
paginationInfo = pagination;
|
||||
}
|
||||
|
||||
this.state.pageInfo = paginationInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* FIXME: Move this inside the component.
|
||||
*
|
||||
* Once the data is received we will start the time ago loops.
|
||||
*
|
||||
* Everytime a request is made like retry or cancel a pipeline, every 10 seconds we
|
||||
* update the time to show how long as passed.
|
||||
*
|
||||
*/
|
||||
static startTimeAgoLoops() {
|
||||
startTimeAgoLoops() {
|
||||
const startTimeLoops = () => {
|
||||
this.timeLoopInterval = setInterval(() => {
|
||||
this.$children[0].$children.reduce((acc, component) => {
|
||||
|
@ -44,5 +59,3 @@ class PipelinesStore {
|
|||
gl.VueRealtimeListener(removeIntervals, startIntervals);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PipelinesStore;
|
|
@ -1,78 +0,0 @@
|
|||
/* global Vue, gl */
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
window.Vue = require('vue');
|
||||
require('../lib/utils/datetime_utility');
|
||||
|
||||
const iconTimerSvg = require('../../../views/shared/icons/_icon_timer.svg');
|
||||
|
||||
((gl) => {
|
||||
gl.VueTimeAgo = Vue.extend({
|
||||
data() {
|
||||
return {
|
||||
currentTime: new Date(),
|
||||
iconTimerSvg,
|
||||
};
|
||||
},
|
||||
props: ['pipeline'],
|
||||
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 class="pipelines-time-ago">
|
||||
<p class="duration" v-if='duration'>
|
||||
<span v-html="iconTimerSvg"></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 = {}));
|
|
@ -1,164 +1,157 @@
|
|||
/* global Vue */
|
||||
window.Vue = require('vue');
|
||||
const commitIconSvg = require('icons/_icon_commit.svg');
|
||||
import commitIconSvg from 'icons/_icon_commit.svg';
|
||||
|
||||
(() => {
|
||||
window.gl = window.gl || {};
|
||||
|
||||
window.gl.CommitComponent = Vue.component('commit-component', {
|
||||
|
||||
props: {
|
||||
/**
|
||||
* Indicates the existance of a tag.
|
||||
* Used to render the correct icon, if true will render `fa-tag` icon,
|
||||
* if false will render `fa-code-fork` icon.
|
||||
*/
|
||||
tag: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
|
||||
/**
|
||||
* If provided is used to render the branch name and url.
|
||||
* Should contain the following properties:
|
||||
* name
|
||||
* ref_url
|
||||
*/
|
||||
commitRef: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
|
||||
/**
|
||||
* Used to link to the commit sha.
|
||||
*/
|
||||
commitUrl: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
|
||||
/**
|
||||
* Used to show the commit short sha that links to the commit url.
|
||||
*/
|
||||
shortSha: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
|
||||
/**
|
||||
* If provided shows the commit tile.
|
||||
*/
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
|
||||
/**
|
||||
* If provided renders information about the author of the commit.
|
||||
* When provided should include:
|
||||
* `avatar_url` to render the avatar icon
|
||||
* `web_url` to link to user profile
|
||||
* `username` to render alt and title tags
|
||||
*/
|
||||
author: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
export default {
|
||||
props: {
|
||||
/**
|
||||
* Indicates the existance of a tag.
|
||||
* Used to render the correct icon, if true will render `fa-tag` icon,
|
||||
* if false will render `fa-code-fork` icon.
|
||||
*/
|
||||
tag: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Used to verify if all the properties needed to render the commit
|
||||
* ref section were provided.
|
||||
*
|
||||
* TODO: Improve this! Use lodash _.has when we have it.
|
||||
*
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
hasCommitRef() {
|
||||
return this.commitRef && this.commitRef.name && this.commitRef.ref_url;
|
||||
},
|
||||
|
||||
/**
|
||||
* Used to verify if all the properties needed to render the commit
|
||||
* author section were provided.
|
||||
*
|
||||
* TODO: Improve this! Use lodash _.has when we have it.
|
||||
*
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
hasAuthor() {
|
||||
return this.author &&
|
||||
this.author.avatar_url &&
|
||||
this.author.web_url &&
|
||||
this.author.username;
|
||||
},
|
||||
|
||||
/**
|
||||
* If information about the author is provided will return a string
|
||||
* to be rendered as the alt attribute of the img tag.
|
||||
*
|
||||
* @returns {String}
|
||||
*/
|
||||
userImageAltDescription() {
|
||||
return this.author &&
|
||||
this.author.username ? `${this.author.username}'s avatar` : null;
|
||||
},
|
||||
/**
|
||||
* If provided is used to render the branch name and url.
|
||||
* Should contain the following properties:
|
||||
* name
|
||||
* ref_url
|
||||
*/
|
||||
commitRef: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
|
||||
data() {
|
||||
return { commitIconSvg };
|
||||
/**
|
||||
* Used to link to the commit sha.
|
||||
*/
|
||||
commitUrl: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
|
||||
template: `
|
||||
<div class="branch-commit">
|
||||
/**
|
||||
* Used to show the commit short sha that links to the commit url.
|
||||
*/
|
||||
shortSha: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
|
||||
<div v-if="hasCommitRef" class="icon-container">
|
||||
<i v-if="tag" class="fa fa-tag"></i>
|
||||
<i v-if="!tag" class="fa fa-code-fork"></i>
|
||||
</div>
|
||||
/**
|
||||
* If provided shows the commit tile.
|
||||
*/
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
|
||||
<a v-if="hasCommitRef"
|
||||
class="monospace branch-name"
|
||||
:href="commitRef.ref_url">
|
||||
{{commitRef.name}}
|
||||
</a>
|
||||
/**
|
||||
* If provided renders information about the author of the commit.
|
||||
* When provided should include:
|
||||
* `avatar_url` to render the avatar icon
|
||||
* `web_url` to link to user profile
|
||||
* `username` to render alt and title tags
|
||||
*/
|
||||
author: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
|
||||
<div v-html="commitIconSvg" class="commit-icon js-commit-icon"></div>
|
||||
computed: {
|
||||
/**
|
||||
* Used to verify if all the properties needed to render the commit
|
||||
* ref section were provided.
|
||||
*
|
||||
* TODO: Improve this! Use lodash _.has when we have it.
|
||||
*
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
hasCommitRef() {
|
||||
return this.commitRef && this.commitRef.name && this.commitRef.ref_url;
|
||||
},
|
||||
|
||||
<a class="commit-id monospace"
|
||||
:href="commitUrl">
|
||||
{{shortSha}}
|
||||
</a>
|
||||
/**
|
||||
* Used to verify if all the properties needed to render the commit
|
||||
* author section were provided.
|
||||
*
|
||||
* TODO: Improve this! Use lodash _.has when we have it.
|
||||
*
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
hasAuthor() {
|
||||
return this.author &&
|
||||
this.author.avatar_url &&
|
||||
this.author.web_url &&
|
||||
this.author.username;
|
||||
},
|
||||
|
||||
<p class="commit-title">
|
||||
<span v-if="title">
|
||||
<a v-if="hasAuthor"
|
||||
class="avatar-image-container"
|
||||
:href="author.web_url">
|
||||
<img
|
||||
class="avatar has-tooltip s20"
|
||||
:src="author.avatar_url"
|
||||
:alt="userImageAltDescription"
|
||||
:title="author.username" />
|
||||
</a>
|
||||
/**
|
||||
* If information about the author is provided will return a string
|
||||
* to be rendered as the alt attribute of the img tag.
|
||||
*
|
||||
* @returns {String}
|
||||
*/
|
||||
userImageAltDescription() {
|
||||
return this.author &&
|
||||
this.author.username ? `${this.author.username}'s avatar` : null;
|
||||
},
|
||||
},
|
||||
|
||||
<a class="commit-row-message"
|
||||
:href="commitUrl">
|
||||
{{title}}
|
||||
</a>
|
||||
</span>
|
||||
<span v-else>
|
||||
Cant find HEAD commit for this branch
|
||||
</span>
|
||||
</p>
|
||||
data() {
|
||||
return { commitIconSvg };
|
||||
},
|
||||
|
||||
template: `
|
||||
<div class="branch-commit">
|
||||
|
||||
<div v-if="hasCommitRef" class="icon-container">
|
||||
<i v-if="tag" class="fa fa-tag"></i>
|
||||
<i v-if="!tag" class="fa fa-code-fork"></i>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})();
|
||||
|
||||
<a v-if="hasCommitRef"
|
||||
class="monospace branch-name"
|
||||
:href="commitRef.ref_url">
|
||||
{{commitRef.name}}
|
||||
</a>
|
||||
|
||||
<div v-html="commitIconSvg" class="commit-icon js-commit-icon"></div>
|
||||
|
||||
<a class="commit-id monospace"
|
||||
:href="commitUrl">
|
||||
{{shortSha}}
|
||||
</a>
|
||||
|
||||
<p class="commit-title">
|
||||
<span v-if="title">
|
||||
<a v-if="hasAuthor"
|
||||
class="avatar-image-container"
|
||||
:href="author.web_url">
|
||||
<img
|
||||
class="avatar has-tooltip s20"
|
||||
:src="author.avatar_url"
|
||||
:alt="userImageAltDescription"
|
||||
:title="author.username" />
|
||||
</a>
|
||||
|
||||
<a class="commit-row-message"
|
||||
:href="commitUrl">
|
||||
{{title}}
|
||||
</a>
|
||||
</span>
|
||||
<span v-else>
|
||||
Cant find HEAD commit for this branch
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
|
|
@ -1,52 +1,48 @@
|
|||
/* eslint-disable no-param-reassign */
|
||||
/* global Vue */
|
||||
import PipelinesTableRowComponent from './pipelines_table_row';
|
||||
|
||||
require('./pipelines_table_row');
|
||||
/**
|
||||
* Pipelines Table Component.
|
||||
*
|
||||
* Given an array of objects, renders a table.
|
||||
*/
|
||||
|
||||
(() => {
|
||||
window.gl = window.gl || {};
|
||||
gl.pipelines = gl.pipelines || {};
|
||||
|
||||
gl.pipelines.PipelinesTableComponent = Vue.component('pipelines-table-component', {
|
||||
|
||||
props: {
|
||||
pipelines: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => ([]),
|
||||
},
|
||||
|
||||
export default {
|
||||
props: {
|
||||
pipelines: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => ([]),
|
||||
},
|
||||
|
||||
components: {
|
||||
'pipelines-table-row-component': gl.pipelines.PipelinesTableRowComponent,
|
||||
service: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
template: `
|
||||
<table class="table ci-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="js-pipeline-status pipeline-status">Status</th>
|
||||
<th class="js-pipeline-info pipeline-info">Pipeline</th>
|
||||
<th class="js-pipeline-commit pipeline-commit">Commit</th>
|
||||
<th class="js-pipeline-stages pipeline-stages">Stages</th>
|
||||
<th class="js-pipeline-date pipeline-date"></th>
|
||||
<th class="js-pipeline-actions pipeline-actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="model in pipelines"
|
||||
v-bind:model="model">
|
||||
<tr is="pipelines-table-row-component"
|
||||
:pipeline="model"></tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
`,
|
||||
});
|
||||
})();
|
||||
components: {
|
||||
'pipelines-table-row-component': PipelinesTableRowComponent,
|
||||
},
|
||||
|
||||
template: `
|
||||
<table class="table ci-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="js-pipeline-status pipeline-status">Status</th>
|
||||
<th class="js-pipeline-info pipeline-info">Pipeline</th>
|
||||
<th class="js-pipeline-commit pipeline-commit">Commit</th>
|
||||
<th class="js-pipeline-stages pipeline-stages">Stages</th>
|
||||
<th class="js-pipeline-date pipeline-date"></th>
|
||||
<th class="js-pipeline-actions pipeline-actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="model in pipelines"
|
||||
v-bind:model="model">
|
||||
<tr is="pipelines-table-row-component"
|
||||
:pipeline="model"
|
||||
:service="service"></tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
`,
|
||||
};
|
||||
|
|
|
@ -1,199 +1,228 @@
|
|||
/* eslint-disable no-param-reassign */
|
||||
/* global Vue */
|
||||
|
||||
require('../../vue_pipelines_index/status');
|
||||
require('../../vue_pipelines_index/pipeline_url');
|
||||
require('../../vue_pipelines_index/stage');
|
||||
require('../../vue_pipelines_index/pipeline_actions');
|
||||
require('../../vue_pipelines_index/time_ago');
|
||||
require('./commit');
|
||||
import AsyncButtonComponent from '../../vue_pipelines_index/components/async_button';
|
||||
import PipelinesActionsComponent from '../../vue_pipelines_index/components/pipelines_actions';
|
||||
import PipelinesArtifactsComponent from '../../vue_pipelines_index/components/pipelines_artifacts';
|
||||
import PipelinesStatusComponent from '../../vue_pipelines_index/components/status';
|
||||
import PipelinesStageComponent from '../../vue_pipelines_index/components/stage';
|
||||
import PipelinesUrlComponent from '../../vue_pipelines_index/components/pipeline_url';
|
||||
import PipelinesTimeagoComponent from '../../vue_pipelines_index/components/time_ago';
|
||||
import CommitComponent from './commit';
|
||||
|
||||
/**
|
||||
* Pipeline table row.
|
||||
*
|
||||
* Given the received object renders a table row in the pipelines' table.
|
||||
*/
|
||||
(() => {
|
||||
window.gl = window.gl || {};
|
||||
gl.pipelines = gl.pipelines || {};
|
||||
|
||||
gl.pipelines.PipelinesTableRowComponent = Vue.component('pipelines-table-row-component', {
|
||||
|
||||
props: {
|
||||
pipeline: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({}),
|
||||
},
|
||||
|
||||
export default {
|
||||
props: {
|
||||
pipeline: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
components: {
|
||||
'commit-component': gl.CommitComponent,
|
||||
'pipeline-actions': gl.VuePipelineActions,
|
||||
'dropdown-stage': gl.VueStage,
|
||||
'pipeline-url': gl.VuePipelineUrl,
|
||||
'status-scope': gl.VueStatusScope,
|
||||
'time-ago': gl.VueTimeAgo,
|
||||
service: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* If provided, returns the commit tag.
|
||||
* Needed to render the commit component column.
|
||||
*
|
||||
* This field needs a lot of verification, because of different possible cases:
|
||||
*
|
||||
* 1. person who is an author of a commit might be a GitLab user
|
||||
* 2. if person who is an author of a commit is a GitLab user he/she can have a GitLab avatar
|
||||
* 3. If GitLab user does not have avatar he/she might have a Gravatar
|
||||
* 4. If committer is not a GitLab User he/she can have a Gravatar
|
||||
* 5. We do not have consistent API object in this case
|
||||
* 6. We should improve API and the code
|
||||
*
|
||||
* @returns {Object|Undefined}
|
||||
*/
|
||||
commitAuthor() {
|
||||
let commitAuthorInformation;
|
||||
components: {
|
||||
'async-button-component': AsyncButtonComponent,
|
||||
'pipelines-actions-component': PipelinesActionsComponent,
|
||||
'pipelines-artifacts-component': PipelinesArtifactsComponent,
|
||||
'commit-component': CommitComponent,
|
||||
'dropdown-stage': PipelinesStageComponent,
|
||||
'pipeline-url': PipelinesUrlComponent,
|
||||
'status-scope': PipelinesStatusComponent,
|
||||
'time-ago': PipelinesTimeagoComponent,
|
||||
},
|
||||
|
||||
// 1. person who is an author of a commit might be a GitLab user
|
||||
if (this.pipeline &&
|
||||
this.pipeline.commit &&
|
||||
this.pipeline.commit.author) {
|
||||
// 2. if person who is an author of a commit is a GitLab user
|
||||
// he/she can have a GitLab avatar
|
||||
if (this.pipeline.commit.author.avatar_url) {
|
||||
commitAuthorInformation = this.pipeline.commit.author;
|
||||
computed: {
|
||||
/**
|
||||
* If provided, returns the commit tag.
|
||||
* Needed to render the commit component column.
|
||||
*
|
||||
* This field needs a lot of verification, because of different possible cases:
|
||||
*
|
||||
* 1. person who is an author of a commit might be a GitLab user
|
||||
* 2. if person who is an author of a commit is a GitLab user he/she can have a GitLab avatar
|
||||
* 3. If GitLab user does not have avatar he/she might have a Gravatar
|
||||
* 4. If committer is not a GitLab User he/she can have a Gravatar
|
||||
* 5. We do not have consistent API object in this case
|
||||
* 6. We should improve API and the code
|
||||
*
|
||||
* @returns {Object|Undefined}
|
||||
*/
|
||||
commitAuthor() {
|
||||
let commitAuthorInformation;
|
||||
|
||||
// 3. If GitLab user does not have avatar he/she might have a Gravatar
|
||||
} else if (this.pipeline.commit.author_gravatar_url) {
|
||||
commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, {
|
||||
avatar_url: this.pipeline.commit.author_gravatar_url,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 1. person who is an author of a commit might be a GitLab user
|
||||
if (this.pipeline &&
|
||||
this.pipeline.commit &&
|
||||
this.pipeline.commit.author) {
|
||||
// 2. if person who is an author of a commit is a GitLab user
|
||||
// he/she can have a GitLab avatar
|
||||
if (this.pipeline.commit.author.avatar_url) {
|
||||
commitAuthorInformation = this.pipeline.commit.author;
|
||||
|
||||
// 4. If committer is not a GitLab User he/she can have a Gravatar
|
||||
if (this.pipeline &&
|
||||
this.pipeline.commit) {
|
||||
commitAuthorInformation = {
|
||||
// 3. If GitLab user does not have avatar he/she might have a Gravatar
|
||||
} else if (this.pipeline.commit.author_gravatar_url) {
|
||||
commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, {
|
||||
avatar_url: this.pipeline.commit.author_gravatar_url,
|
||||
web_url: `mailto:${this.pipeline.commit.author_email}`,
|
||||
username: this.pipeline.commit.author_name,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return commitAuthorInformation;
|
||||
},
|
||||
// 4. If committer is not a GitLab User he/she can have a Gravatar
|
||||
if (this.pipeline &&
|
||||
this.pipeline.commit) {
|
||||
commitAuthorInformation = {
|
||||
avatar_url: this.pipeline.commit.author_gravatar_url,
|
||||
web_url: `mailto:${this.pipeline.commit.author_email}`,
|
||||
username: this.pipeline.commit.author_name,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* If provided, returns the commit tag.
|
||||
* Needed to render the commit component column.
|
||||
*
|
||||
* @returns {String|Undefined}
|
||||
*/
|
||||
commitTag() {
|
||||
if (this.pipeline.ref &&
|
||||
this.pipeline.ref.tag) {
|
||||
return this.pipeline.ref.tag;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* If provided, returns the commit ref.
|
||||
* Needed to render the commit component column.
|
||||
*
|
||||
* Matches `path` prop sent in the API to `ref_url` prop needed
|
||||
* in the commit component.
|
||||
*
|
||||
* @returns {Object|Undefined}
|
||||
*/
|
||||
commitRef() {
|
||||
if (this.pipeline.ref) {
|
||||
return Object.keys(this.pipeline.ref).reduce((accumulator, prop) => {
|
||||
if (prop === 'path') {
|
||||
accumulator.ref_url = this.pipeline.ref[prop];
|
||||
} else {
|
||||
accumulator[prop] = this.pipeline.ref[prop];
|
||||
}
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* If provided, returns the commit url.
|
||||
* Needed to render the commit component column.
|
||||
*
|
||||
* @returns {String|Undefined}
|
||||
*/
|
||||
commitUrl() {
|
||||
if (this.pipeline.commit &&
|
||||
this.pipeline.commit.commit_path) {
|
||||
return this.pipeline.commit.commit_path;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* If provided, returns the commit short sha.
|
||||
* Needed to render the commit component column.
|
||||
*
|
||||
* @returns {String|Undefined}
|
||||
*/
|
||||
commitShortSha() {
|
||||
if (this.pipeline.commit &&
|
||||
this.pipeline.commit.short_id) {
|
||||
return this.pipeline.commit.short_id;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* If provided, returns the commit title.
|
||||
* Needed to render the commit component column.
|
||||
*
|
||||
* @returns {String|Undefined}
|
||||
*/
|
||||
commitTitle() {
|
||||
if (this.pipeline.commit &&
|
||||
this.pipeline.commit.title) {
|
||||
return this.pipeline.commit.title;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
return commitAuthorInformation;
|
||||
},
|
||||
|
||||
template: `
|
||||
<tr class="commit">
|
||||
<status-scope :pipeline="pipeline"/>
|
||||
/**
|
||||
* If provided, returns the commit tag.
|
||||
* Needed to render the commit component column.
|
||||
*
|
||||
* @returns {String|Undefined}
|
||||
*/
|
||||
commitTag() {
|
||||
if (this.pipeline.ref &&
|
||||
this.pipeline.ref.tag) {
|
||||
return this.pipeline.ref.tag;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
<pipeline-url :pipeline="pipeline"></pipeline-url>
|
||||
/**
|
||||
* If provided, returns the commit ref.
|
||||
* Needed to render the commit component column.
|
||||
*
|
||||
* Matches `path` prop sent in the API to `ref_url` prop needed
|
||||
* in the commit component.
|
||||
*
|
||||
* @returns {Object|Undefined}
|
||||
*/
|
||||
commitRef() {
|
||||
if (this.pipeline.ref) {
|
||||
return Object.keys(this.pipeline.ref).reduce((accumulator, prop) => {
|
||||
if (prop === 'path') {
|
||||
accumulator.ref_url = this.pipeline.ref[prop];
|
||||
} else {
|
||||
accumulator[prop] = this.pipeline.ref[prop];
|
||||
}
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
<td>
|
||||
<commit-component
|
||||
:tag="commitTag"
|
||||
:commit-ref="commitRef"
|
||||
:commit-url="commitUrl"
|
||||
:short-sha="commitShortSha"
|
||||
:title="commitTitle"
|
||||
:author="commitAuthor"/>
|
||||
</td>
|
||||
return undefined;
|
||||
},
|
||||
|
||||
<td class="stage-cell">
|
||||
<div class="stage-container dropdown js-mini-pipeline-graph"
|
||||
v-if="pipeline.details.stages.length > 0"
|
||||
v-for="stage in pipeline.details.stages">
|
||||
<dropdown-stage :stage="stage"/>
|
||||
</div>
|
||||
</td>
|
||||
/**
|
||||
* If provided, returns the commit url.
|
||||
* Needed to render the commit component column.
|
||||
*
|
||||
* @returns {String|Undefined}
|
||||
*/
|
||||
commitUrl() {
|
||||
if (this.pipeline.commit &&
|
||||
this.pipeline.commit.commit_path) {
|
||||
return this.pipeline.commit.commit_path;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
<time-ago :pipeline="pipeline"/>
|
||||
/**
|
||||
* If provided, returns the commit short sha.
|
||||
* Needed to render the commit component column.
|
||||
*
|
||||
* @returns {String|Undefined}
|
||||
*/
|
||||
commitShortSha() {
|
||||
if (this.pipeline.commit &&
|
||||
this.pipeline.commit.short_id) {
|
||||
return this.pipeline.commit.short_id;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
<pipeline-actions :pipeline="pipeline" />
|
||||
</tr>
|
||||
`,
|
||||
});
|
||||
})();
|
||||
/**
|
||||
* If provided, returns the commit title.
|
||||
* Needed to render the commit component column.
|
||||
*
|
||||
* @returns {String|Undefined}
|
||||
*/
|
||||
commitTitle() {
|
||||
if (this.pipeline.commit &&
|
||||
this.pipeline.commit.title) {
|
||||
return this.pipeline.commit.title;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
|
||||
template: `
|
||||
<tr class="commit">
|
||||
<status-scope :pipeline="pipeline"/>
|
||||
|
||||
<pipeline-url :pipeline="pipeline"></pipeline-url>
|
||||
|
||||
<td>
|
||||
<commit-component
|
||||
:tag="commitTag"
|
||||
:commit-ref="commitRef"
|
||||
:commit-url="commitUrl"
|
||||
:short-sha="commitShortSha"
|
||||
:title="commitTitle"
|
||||
:author="commitAuthor"/>
|
||||
</td>
|
||||
|
||||
<td class="stage-cell">
|
||||
<div class="stage-container dropdown js-mini-pipeline-graph"
|
||||
v-if="pipeline.details.stages.length > 0"
|
||||
v-for="stage in pipeline.details.stages">
|
||||
<dropdown-stage :stage="stage"/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<time-ago :pipeline="pipeline"/>
|
||||
|
||||
<td class="pipeline-actions">
|
||||
<div class="pull-right btn-group">
|
||||
<pipelines-actions-component
|
||||
v-if="pipeline.details.manual_actions.length"
|
||||
:actions="pipeline.details.manual_actions"
|
||||
:service="service" />
|
||||
|
||||
<pipelines-artifacts-component
|
||||
v-if="pipeline.details.artifacts.length"
|
||||
:artifacts="pipeline.details.artifacts" />
|
||||
|
||||
<async-button-component
|
||||
v-if="pipeline.flags.retryable"
|
||||
:service="service"
|
||||
:endpoint="pipeline.retry_path"
|
||||
css-class="js-pipelines-retry-button btn-default btn-retry"
|
||||
title="Retry"
|
||||
icon="repeat" />
|
||||
|
||||
<async-button-component
|
||||
v-if="pipeline.flags.cancelable"
|
||||
:service="service"
|
||||
:endpoint="pipeline.cancel_path"
|
||||
css-class="js-pipelines-cancel-button btn-remove"
|
||||
title="Cancel"
|
||||
icon="remove"
|
||||
confirm-action-message="Are you sure you want to cancel this pipeline?" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`,
|
||||
};
|
||||
|
|
|
@ -1,147 +1,135 @@
|
|||
/* global Vue, gl */
|
||||
/* eslint-disable no-param-reassign, no-plusplus */
|
||||
const PAGINATION_UI_BUTTON_LIMIT = 4;
|
||||
const UI_LIMIT = 6;
|
||||
const SPREAD = '...';
|
||||
const PREV = 'Prev';
|
||||
const NEXT = 'Next';
|
||||
const FIRST = '<< First';
|
||||
const LAST = 'Last >>';
|
||||
|
||||
window.Vue = require('vue');
|
||||
export default {
|
||||
props: {
|
||||
/**
|
||||
This function will take the information given by the pagination component
|
||||
|
||||
((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 >>';
|
||||
Here is an example `change` method:
|
||||
|
||||
gl.VueGlPagination = Vue.extend({
|
||||
props: {
|
||||
|
||||
// TODO: Consider refactoring in light of turbolinks removal.
|
||||
|
||||
/**
|
||||
This function will take the information given by the pagination component
|
||||
|
||||
Here is an example `change` method:
|
||||
|
||||
change(pagenum) {
|
||||
gl.utils.visitUrl(`?page=${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,
|
||||
change(pagenum) {
|
||||
gl.utils.visitUrl(`?page=${pagenum}`);
|
||||
},
|
||||
*/
|
||||
change: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
methods: {
|
||||
changePage(e) {
|
||||
const text = e.target.innerText;
|
||||
const { totalPages, nextPage, previousPage } = this.pageInfo;
|
||||
|
||||
switch (text) {
|
||||
case SPREAD:
|
||||
break;
|
||||
case LAST:
|
||||
this.change(totalPages);
|
||||
break;
|
||||
case NEXT:
|
||||
this.change(nextPage);
|
||||
break;
|
||||
case PREV:
|
||||
this.change(previousPage);
|
||||
break;
|
||||
case FIRST:
|
||||
this.change(1);
|
||||
break;
|
||||
default:
|
||||
this.change(+text);
|
||||
break;
|
||||
}
|
||||
},
|
||||
/**
|
||||
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,
|
||||
},
|
||||
computed: {
|
||||
prev() {
|
||||
return this.pageInfo.previousPage;
|
||||
},
|
||||
next() {
|
||||
return this.pageInfo.nextPage;
|
||||
},
|
||||
getItems() {
|
||||
const total = this.pageInfo.totalPages;
|
||||
const page = this.pageInfo.page;
|
||||
const items = [];
|
||||
},
|
||||
methods: {
|
||||
changePage(e) {
|
||||
const text = e.target.innerText;
|
||||
const { totalPages, nextPage, previousPage } = this.pageInfo;
|
||||
|
||||
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;
|
||||
},
|
||||
switch (text) {
|
||||
case SPREAD:
|
||||
break;
|
||||
case LAST:
|
||||
this.change(totalPages);
|
||||
break;
|
||||
case NEXT:
|
||||
this.change(nextPage);
|
||||
break;
|
||||
case PREV:
|
||||
this.change(previousPage);
|
||||
break;
|
||||
case FIRST:
|
||||
this.change(1);
|
||||
break;
|
||||
default:
|
||||
this.change(+text);
|
||||
break;
|
||||
}
|
||||
},
|
||||
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 = {}));
|
||||
},
|
||||
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 += 1) {
|
||||
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>
|
||||
`,
|
||||
};
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
/* eslint-disable func-names, prefer-arrow-callback, no-unused-vars,
|
||||
no-param-reassign, no-plusplus */
|
||||
/* global Vue */
|
||||
/* eslint-disable no-param-reassign, no-plusplus */
|
||||
import Vue from 'vue';
|
||||
import VueResource from 'vue-resource';
|
||||
|
||||
Vue.use(VueResource);
|
||||
|
||||
Vue.http.interceptors.push((request, next) => {
|
||||
Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1;
|
||||
|
||||
next((response) => {
|
||||
next(() => {
|
||||
Vue.activeResources--;
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,5 +2,6 @@ gl-emoji {
|
|||
display: inline-block;
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
|
|
@ -72,11 +72,6 @@
|
|||
color: $gl-text-color-secondary;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
svg,
|
||||
.fa {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
|
@ -921,3 +916,22 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play button with icon in dropdowns
|
||||
*/
|
||||
.ci-table .no-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
.icon-play {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
margin-right: 5px;
|
||||
height: 13px;
|
||||
width: 12px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -316,6 +316,7 @@ class ProjectsController < Projects::ApplicationController
|
|||
:namespace_id,
|
||||
:only_allow_merge_if_all_discussions_are_resolved,
|
||||
:only_allow_merge_if_pipeline_succeeds,
|
||||
:printing_merge_request_link_enabled,
|
||||
:path,
|
||||
:public_builds,
|
||||
:request_access_enabled,
|
||||
|
|
|
@ -321,7 +321,14 @@ class Commit
|
|||
end
|
||||
|
||||
def raw_diffs(*args)
|
||||
raw.diffs(*args)
|
||||
use_gitaly = Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs)
|
||||
deltas_only = args.last.is_a?(Hash) && args.last[:deltas_only]
|
||||
|
||||
if use_gitaly && !deltas_only
|
||||
Gitlab::GitalyClient::Commit.diff_from_parent(self, *args)
|
||||
else
|
||||
raw.diffs(*args)
|
||||
end
|
||||
end
|
||||
|
||||
def diffs(diff_options = nil)
|
||||
|
|
|
@ -261,6 +261,7 @@ module Issuable
|
|||
user: user.hook_attrs,
|
||||
project: project.hook_attrs,
|
||||
object_attributes: hook_attrs,
|
||||
labels: labels.map(&:hook_attrs),
|
||||
# DEPRECATED
|
||||
repository: project.hook_attrs.slice(:name, :url, :description, :homepage)
|
||||
}
|
||||
|
|
|
@ -169,6 +169,10 @@ class Label < ActiveRecord::Base
|
|||
end
|
||||
end
|
||||
|
||||
def hook_attrs
|
||||
attributes
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def issues_count(user, params = {})
|
||||
|
|
|
@ -7,6 +7,8 @@ module MergeRequests
|
|||
end
|
||||
|
||||
def execute(changes)
|
||||
return [] unless project.printing_merge_request_link_enabled
|
||||
|
||||
branches = get_branches(changes)
|
||||
merge_requests_map = opened_merge_requests_from_source_branches(branches)
|
||||
branches.map do |branch|
|
||||
|
|
293
app/services/notification_recipient_service.rb
Normal file
|
@ -0,0 +1,293 @@
|
|||
#
|
||||
# Used by NotificationService to determine who should receive notification
|
||||
#
|
||||
class NotificationRecipientService
|
||||
attr_reader :project
|
||||
|
||||
def initialize(project)
|
||||
@project = project
|
||||
end
|
||||
|
||||
def build_recipients(target, current_user, action: nil, previous_assignee: nil, skip_current_user: true)
|
||||
custom_action = build_custom_key(action, target)
|
||||
|
||||
recipients = target.participants(current_user)
|
||||
|
||||
unless NotificationSetting::EXCLUDED_WATCHER_EVENTS.include?(custom_action)
|
||||
recipients = add_project_watchers(recipients)
|
||||
end
|
||||
|
||||
recipients = add_custom_notifications(recipients, custom_action)
|
||||
recipients = reject_mention_users(recipients)
|
||||
|
||||
# Re-assign is considered as a mention of the new assignee so we add the
|
||||
# new assignee to the list of recipients after we rejected users with
|
||||
# the "on mention" notification level
|
||||
if [:reassign_merge_request, :reassign_issue].include?(custom_action)
|
||||
recipients << previous_assignee if previous_assignee
|
||||
recipients << target.assignee
|
||||
end
|
||||
|
||||
recipients = reject_muted_users(recipients)
|
||||
recipients = add_subscribed_users(recipients, target)
|
||||
|
||||
if [:new_issue, :new_merge_request].include?(custom_action)
|
||||
recipients = add_labels_subscribers(recipients, target)
|
||||
end
|
||||
|
||||
recipients = reject_unsubscribed_users(recipients, target)
|
||||
recipients = reject_users_without_access(recipients, target)
|
||||
|
||||
recipients.delete(current_user) if skip_current_user
|
||||
|
||||
recipients.uniq
|
||||
end
|
||||
|
||||
def build_relabeled_recipients(target, current_user, labels:)
|
||||
recipients = add_labels_subscribers([], target, labels: labels)
|
||||
recipients = reject_unsubscribed_users(recipients, target)
|
||||
recipients = reject_users_without_access(recipients, target)
|
||||
recipients.delete(current_user)
|
||||
recipients.uniq
|
||||
end
|
||||
|
||||
def build_new_note_recipients(note)
|
||||
target = note.noteable
|
||||
|
||||
ability, subject = if note.for_personal_snippet?
|
||||
[:read_personal_snippet, note.noteable]
|
||||
else
|
||||
[:read_project, note.project]
|
||||
end
|
||||
|
||||
mentioned_users = note.mentioned_users.select { |user| user.can?(ability, subject) }
|
||||
|
||||
# Add all users participating in the thread (author, assignee, comment authors)
|
||||
recipients =
|
||||
if target.respond_to?(:participants)
|
||||
target.participants(note.author)
|
||||
else
|
||||
mentioned_users
|
||||
end
|
||||
|
||||
unless note.for_personal_snippet?
|
||||
# Merge project watchers
|
||||
recipients = add_project_watchers(recipients)
|
||||
|
||||
# Merge project with custom notification
|
||||
recipients = add_custom_notifications(recipients, :new_note)
|
||||
end
|
||||
|
||||
# Reject users with Mention notification level, except those mentioned in _this_ note.
|
||||
recipients = reject_mention_users(recipients - mentioned_users)
|
||||
recipients = recipients + mentioned_users
|
||||
|
||||
recipients = reject_muted_users(recipients)
|
||||
|
||||
recipients = add_subscribed_users(recipients, note.noteable)
|
||||
recipients = reject_unsubscribed_users(recipients, note.noteable)
|
||||
recipients = reject_users_without_access(recipients, note.noteable)
|
||||
|
||||
recipients.delete(note.author)
|
||||
recipients.uniq
|
||||
end
|
||||
|
||||
# Remove users with disabled notifications from array
|
||||
# Also remove duplications and nil recipients
|
||||
def reject_muted_users(users)
|
||||
reject_users(users, :disabled)
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
# Get project/group users with CUSTOM notification level
|
||||
def add_custom_notifications(recipients, action)
|
||||
user_ids = []
|
||||
|
||||
# Users with a notification setting on group or project
|
||||
user_ids += user_ids_notifiable_on(project, :custom, action)
|
||||
user_ids += user_ids_notifiable_on(project.group, :custom, action)
|
||||
|
||||
# Users with global level custom
|
||||
user_ids_with_project_level_global = user_ids_notifiable_on(project, :global)
|
||||
user_ids_with_group_level_global = user_ids_notifiable_on(project.group, :global)
|
||||
|
||||
global_users_ids = user_ids_with_project_level_global.concat(user_ids_with_group_level_global)
|
||||
user_ids += user_ids_with_global_level_custom(global_users_ids, action)
|
||||
|
||||
recipients.concat(User.find(user_ids))
|
||||
end
|
||||
|
||||
def add_project_watchers(recipients)
|
||||
recipients.concat(project_watchers).compact
|
||||
end
|
||||
|
||||
# Get project users with WATCH notification level
|
||||
def project_watchers
|
||||
project_members_ids = user_ids_notifiable_on(project)
|
||||
|
||||
user_ids_with_project_global = user_ids_notifiable_on(project, :global)
|
||||
user_ids_with_group_global = user_ids_notifiable_on(project.group, :global)
|
||||
|
||||
user_ids = user_ids_with_global_level_watch((user_ids_with_project_global + user_ids_with_group_global).uniq)
|
||||
|
||||
user_ids_with_project_setting = select_project_members_ids(project, user_ids_with_project_global, user_ids)
|
||||
user_ids_with_group_setting = select_group_members_ids(project.group, project_members_ids, user_ids_with_group_global, user_ids)
|
||||
|
||||
User.where(id: user_ids_with_project_setting.concat(user_ids_with_group_setting).uniq).to_a
|
||||
end
|
||||
|
||||
# Remove users with notification level 'Mentioned'
|
||||
def reject_mention_users(users)
|
||||
reject_users(users, :mention)
|
||||
end
|
||||
|
||||
def add_subscribed_users(recipients, target)
|
||||
return recipients unless target.respond_to? :subscribers
|
||||
|
||||
recipients + target.subscribers(project)
|
||||
end
|
||||
|
||||
def user_ids_notifiable_on(resource, notification_level = nil, action = nil)
|
||||
return [] unless resource
|
||||
|
||||
if notification_level
|
||||
settings = resource.notification_settings.where(level: NotificationSetting.levels[notification_level])
|
||||
settings = settings.select { |setting| setting.events[action] } if action.present?
|
||||
settings.map(&:user_id)
|
||||
else
|
||||
resource.notification_settings.pluck(:user_id)
|
||||
end
|
||||
end
|
||||
|
||||
# Build a list of user_ids based on project notification settings
|
||||
def select_project_members_ids(project, global_setting, user_ids_global_level_watch)
|
||||
user_ids = user_ids_notifiable_on(project, :watch)
|
||||
|
||||
# If project setting is global, add to watch list if global setting is watch
|
||||
global_setting.each do |user_id|
|
||||
if user_ids_global_level_watch.include?(user_id)
|
||||
user_ids << user_id
|
||||
end
|
||||
end
|
||||
|
||||
user_ids
|
||||
end
|
||||
|
||||
# Build a list of user_ids based on group notification settings
|
||||
def select_group_members_ids(group, project_members, global_setting, user_ids_global_level_watch)
|
||||
uids = user_ids_notifiable_on(group, :watch)
|
||||
|
||||
# Group setting is watch, add to user_ids list if user is not project member
|
||||
user_ids = []
|
||||
uids.each do |user_id|
|
||||
if project_members.exclude?(user_id)
|
||||
user_ids << user_id
|
||||
end
|
||||
end
|
||||
|
||||
# Group setting is global, add to user_ids list if global setting is watch
|
||||
global_setting.each do |user_id|
|
||||
if project_members.exclude?(user_id) && user_ids_global_level_watch.include?(user_id)
|
||||
user_ids << user_id
|
||||
end
|
||||
end
|
||||
|
||||
user_ids
|
||||
end
|
||||
|
||||
def user_ids_with_global_level_watch(ids)
|
||||
settings_with_global_level_of(:watch, ids).pluck(:user_id)
|
||||
end
|
||||
|
||||
def user_ids_with_global_level_custom(ids, action)
|
||||
settings = settings_with_global_level_of(:custom, ids)
|
||||
settings = settings.select { |setting| setting.events[action] }
|
||||
settings.map(&:user_id)
|
||||
end
|
||||
|
||||
def settings_with_global_level_of(level, ids)
|
||||
NotificationSetting.where(
|
||||
user_id: ids,
|
||||
source_type: nil,
|
||||
level: NotificationSetting.levels[level]
|
||||
)
|
||||
end
|
||||
|
||||
# Reject users which has certain notification level
|
||||
#
|
||||
# Example:
|
||||
# reject_users(users, :watch, project)
|
||||
#
|
||||
def reject_users(users, level)
|
||||
level = level.to_s
|
||||
|
||||
unless NotificationSetting.levels.keys.include?(level)
|
||||
raise 'Invalid notification level'
|
||||
end
|
||||
|
||||
users = users.to_a.compact.uniq
|
||||
users = users.select { |u| u.can?(:receive_notifications) }
|
||||
|
||||
users.reject do |user|
|
||||
global_notification_setting = user.global_notification_setting
|
||||
|
||||
next global_notification_setting.level == level unless project
|
||||
|
||||
setting = user.notification_settings_for(project)
|
||||
|
||||
if project.group && (setting.nil? || setting.global?)
|
||||
setting = user.notification_settings_for(project.group)
|
||||
end
|
||||
|
||||
# reject users who globally set mention notification and has no setting per project/group
|
||||
next global_notification_setting.level == level unless setting
|
||||
|
||||
# reject users who set mention notification in project
|
||||
next true if setting.level == level
|
||||
|
||||
# reject users who have mention level in project and disabled in global settings
|
||||
setting.global? && global_notification_setting.level == level
|
||||
end
|
||||
end
|
||||
|
||||
def reject_unsubscribed_users(recipients, target)
|
||||
return recipients unless target.respond_to? :subscriptions
|
||||
|
||||
recipients.reject do |user|
|
||||
subscription = target.subscriptions.find_by_user_id(user.id)
|
||||
subscription && !subscription.subscribed
|
||||
end
|
||||
end
|
||||
|
||||
def reject_users_without_access(recipients, target)
|
||||
ability = case target
|
||||
when Issuable
|
||||
:"read_#{target.to_ability_name}"
|
||||
when Ci::Pipeline
|
||||
:read_build # We have build trace in pipeline emails
|
||||
end
|
||||
|
||||
return recipients unless ability
|
||||
|
||||
recipients.select do |user|
|
||||
user.can?(ability, target)
|
||||
end
|
||||
end
|
||||
|
||||
def add_labels_subscribers(recipients, target, labels: nil)
|
||||
return recipients unless target.respond_to? :labels
|
||||
|
||||
(labels || target.labels).each do |label|
|
||||
recipients += label.subscribers(project)
|
||||
end
|
||||
|
||||
recipients
|
||||
end
|
||||
|
||||
# Build event key to search on custom notification level
|
||||
# Check NotificationSetting::EMAIL_EVENTS
|
||||
def build_custom_key(action, object)
|
||||
"#{action}_#{object.class.model_name.name.underscore}".to_sym
|
||||
end
|
||||
end
|
|
@ -150,7 +150,10 @@ class NotificationService
|
|||
end
|
||||
|
||||
def resolve_all_discussions(merge_request, current_user)
|
||||
recipients = build_recipients(merge_request, merge_request.target_project, current_user, action: "resolve_all_discussions")
|
||||
recipients = NotificationRecipientService.new(merge_request.target_project).build_recipients(
|
||||
merge_request,
|
||||
current_user,
|
||||
action: "resolve_all_discussions")
|
||||
|
||||
recipients.each do |recipient|
|
||||
mailer.resolved_all_discussions_email(recipient.id, merge_request.id, current_user.id).deliver_later
|
||||
|
@ -164,64 +167,15 @@ class NotificationService
|
|||
end
|
||||
|
||||
# Notify users on new note in system
|
||||
#
|
||||
# TODO: split on methods and refactor
|
||||
#
|
||||
def new_note(note)
|
||||
return true unless note.noteable_type.present?
|
||||
|
||||
# ignore gitlab service messages
|
||||
return true if note.cross_reference? && note.system?
|
||||
|
||||
target = note.noteable
|
||||
|
||||
recipients = []
|
||||
|
||||
mentioned_users = note.mentioned_users
|
||||
|
||||
ability, subject = if note.for_personal_snippet?
|
||||
[:read_personal_snippet, note.noteable]
|
||||
else
|
||||
[:read_project, note.project]
|
||||
end
|
||||
|
||||
mentioned_users.select! do |user|
|
||||
user.can?(ability, subject)
|
||||
end
|
||||
|
||||
# Add all users participating in the thread (author, assignee, comment authors)
|
||||
participants =
|
||||
if target.respond_to?(:participants)
|
||||
target.participants(note.author)
|
||||
else
|
||||
mentioned_users
|
||||
end
|
||||
|
||||
recipients = recipients.concat(participants)
|
||||
|
||||
unless note.for_personal_snippet?
|
||||
# Merge project watchers
|
||||
recipients = add_project_watchers(recipients, note.project)
|
||||
|
||||
# Merge project with custom notification
|
||||
recipients = add_custom_notifications(recipients, note.project, :new_note)
|
||||
end
|
||||
|
||||
# Reject users with Mention notification level, except those mentioned in _this_ note.
|
||||
recipients = reject_mention_users(recipients - mentioned_users, note.project)
|
||||
recipients = recipients + mentioned_users
|
||||
|
||||
recipients = reject_muted_users(recipients, note.project)
|
||||
|
||||
recipients = add_subscribed_users(recipients, note.project, note.noteable)
|
||||
recipients = reject_unsubscribed_users(recipients, note.noteable)
|
||||
recipients = reject_users_without_access(recipients, note.noteable)
|
||||
|
||||
recipients.delete(note.author)
|
||||
recipients = recipients.uniq
|
||||
|
||||
notify_method = "note_#{note.to_ability_name}_email".to_sym
|
||||
|
||||
recipients = NotificationRecipientService.new(note.project).build_new_note_recipients(note)
|
||||
recipients.each do |recipient|
|
||||
mailer.send(notify_method, recipient.id, note.id).deliver_later
|
||||
end
|
||||
|
@ -290,7 +244,7 @@ class NotificationService
|
|||
|
||||
def project_was_moved(project, old_path_with_namespace)
|
||||
recipients = project.team.members
|
||||
recipients = reject_muted_users(recipients, project)
|
||||
recipients = NotificationRecipientService.new(project).reject_muted_users(recipients)
|
||||
|
||||
recipients.each do |recipient|
|
||||
mailer.project_was_moved_email(
|
||||
|
@ -302,7 +256,7 @@ class NotificationService
|
|||
end
|
||||
|
||||
def issue_moved(issue, new_issue, current_user)
|
||||
recipients = build_recipients(issue, issue.project, current_user)
|
||||
recipients = NotificationRecipientService.new(issue.project).build_recipients(issue, current_user)
|
||||
|
||||
recipients.map do |recipient|
|
||||
email = mailer.issue_moved_email(recipient, issue, new_issue, current_user)
|
||||
|
@ -324,9 +278,8 @@ class NotificationService
|
|||
|
||||
return unless mailer.respond_to?(email_template)
|
||||
|
||||
recipients ||= build_recipients(
|
||||
recipients ||= NotificationRecipientService.new(pipeline.project).build_recipients(
|
||||
pipeline,
|
||||
pipeline.project,
|
||||
nil, # The acting user, who won't be added to recipients
|
||||
action: pipeline.status).map(&:notification_email)
|
||||
|
||||
|
@ -337,199 +290,8 @@ class NotificationService
|
|||
|
||||
protected
|
||||
|
||||
# Get project/group users with CUSTOM notification level
|
||||
def add_custom_notifications(recipients, project, action)
|
||||
user_ids = []
|
||||
|
||||
# Users with a notification setting on group or project
|
||||
user_ids += notification_settings_for(project, :custom, action)
|
||||
user_ids += notification_settings_for(project.group, :custom, action)
|
||||
|
||||
# Users with global level custom
|
||||
users_with_project_level_global = notification_settings_for(project, :global)
|
||||
users_with_group_level_global = notification_settings_for(project.group, :global)
|
||||
|
||||
global_users_ids = users_with_project_level_global.concat(users_with_group_level_global)
|
||||
user_ids += users_with_global_level_custom(global_users_ids, action)
|
||||
|
||||
recipients.concat(User.find(user_ids))
|
||||
end
|
||||
|
||||
# Get project users with WATCH notification level
|
||||
def project_watchers(project)
|
||||
project_members = notification_settings_for(project)
|
||||
|
||||
users_with_project_level_global = notification_settings_for(project, :global)
|
||||
users_with_group_level_global = notification_settings_for(project.group, :global)
|
||||
|
||||
users = users_with_global_level_watch([users_with_project_level_global, users_with_group_level_global].flatten.uniq)
|
||||
|
||||
users_with_project_setting = select_project_member_setting(project, users_with_project_level_global, users)
|
||||
users_with_group_setting = select_group_member_setting(project.group, project_members, users_with_group_level_global, users)
|
||||
|
||||
User.where(id: users_with_project_setting.concat(users_with_group_setting).uniq).to_a
|
||||
end
|
||||
|
||||
def notification_settings_for(resource, notification_level = nil, action = nil)
|
||||
return [] unless resource
|
||||
|
||||
if notification_level
|
||||
settings = resource.notification_settings.where(level: NotificationSetting.levels[notification_level])
|
||||
settings = settings.select { |setting| setting.events[action] } if action.present?
|
||||
settings.map(&:user_id)
|
||||
else
|
||||
resource.notification_settings.pluck(:user_id)
|
||||
end
|
||||
end
|
||||
|
||||
def users_with_global_level_watch(ids)
|
||||
settings_with_global_level_of(:watch, ids).pluck(:user_id)
|
||||
end
|
||||
|
||||
def users_with_global_level_custom(ids, action)
|
||||
settings = settings_with_global_level_of(:custom, ids)
|
||||
settings = settings.select { |setting| setting.events[action] }
|
||||
settings.map(&:user_id)
|
||||
end
|
||||
|
||||
def settings_with_global_level_of(level, ids)
|
||||
NotificationSetting.where(
|
||||
user_id: ids,
|
||||
source_type: nil,
|
||||
level: NotificationSetting.levels[level]
|
||||
)
|
||||
end
|
||||
|
||||
# Build a list of users based on project notification settings
|
||||
def select_project_member_setting(project, global_setting, users_global_level_watch)
|
||||
users = notification_settings_for(project, :watch)
|
||||
|
||||
# If project setting is global, add to watch list if global setting is watch
|
||||
global_setting.each do |user_id|
|
||||
if users_global_level_watch.include?(user_id)
|
||||
users << user_id
|
||||
end
|
||||
end
|
||||
|
||||
users
|
||||
end
|
||||
|
||||
# Build a list of users based on group notification settings
|
||||
def select_group_member_setting(group, project_members, global_setting, users_global_level_watch)
|
||||
uids = notification_settings_for(group, :watch)
|
||||
|
||||
# Group setting is watch, add to users list if user is not project member
|
||||
users = []
|
||||
uids.each do |user_id|
|
||||
if project_members.exclude?(user_id)
|
||||
users << user_id
|
||||
end
|
||||
end
|
||||
|
||||
# Group setting is global, add to users list if global setting is watch
|
||||
global_setting.each do |user_id|
|
||||
if project_members.exclude?(user_id) && users_global_level_watch.include?(user_id)
|
||||
users << user_id
|
||||
end
|
||||
end
|
||||
|
||||
users
|
||||
end
|
||||
|
||||
def add_project_watchers(recipients, project)
|
||||
recipients.concat(project_watchers(project)).compact
|
||||
end
|
||||
|
||||
# Remove users with disabled notifications from array
|
||||
# Also remove duplications and nil recipients
|
||||
def reject_muted_users(users, project = nil)
|
||||
reject_users(users, :disabled, project)
|
||||
end
|
||||
|
||||
# Remove users with notification level 'Mentioned'
|
||||
def reject_mention_users(users, project = nil)
|
||||
reject_users(users, :mention, project)
|
||||
end
|
||||
|
||||
# Reject users which has certain notification level
|
||||
#
|
||||
# Example:
|
||||
# reject_users(users, :watch, project)
|
||||
#
|
||||
def reject_users(users, level, project = nil)
|
||||
level = level.to_s
|
||||
|
||||
unless NotificationSetting.levels.keys.include?(level)
|
||||
raise 'Invalid notification level'
|
||||
end
|
||||
|
||||
users = users.to_a.compact.uniq
|
||||
users = users.select { |u| u.can?(:receive_notifications) }
|
||||
|
||||
users.reject do |user|
|
||||
global_notification_setting = user.global_notification_setting
|
||||
|
||||
next global_notification_setting.level == level unless project
|
||||
|
||||
setting = user.notification_settings_for(project)
|
||||
|
||||
if project.group && (setting.nil? || setting.global?)
|
||||
setting = user.notification_settings_for(project.group)
|
||||
end
|
||||
|
||||
# reject users who globally set mention notification and has no setting per project/group
|
||||
next global_notification_setting.level == level unless setting
|
||||
|
||||
# reject users who set mention notification in project
|
||||
next true if setting.level == level
|
||||
|
||||
# reject users who have mention level in project and disabled in global settings
|
||||
setting.global? && global_notification_setting.level == level
|
||||
end
|
||||
end
|
||||
|
||||
def reject_unsubscribed_users(recipients, target)
|
||||
return recipients unless target.respond_to? :subscriptions
|
||||
|
||||
recipients.reject do |user|
|
||||
subscription = target.subscriptions.find_by_user_id(user.id)
|
||||
subscription && !subscription.subscribed
|
||||
end
|
||||
end
|
||||
|
||||
def reject_users_without_access(recipients, target)
|
||||
ability = case target
|
||||
when Issuable
|
||||
:"read_#{target.to_ability_name}"
|
||||
when Ci::Pipeline
|
||||
:read_build # We have build trace in pipeline emails
|
||||
end
|
||||
|
||||
return recipients unless ability
|
||||
|
||||
recipients.select do |user|
|
||||
user.can?(ability, target)
|
||||
end
|
||||
end
|
||||
|
||||
def add_subscribed_users(recipients, project, target)
|
||||
return recipients unless target.respond_to? :subscribers
|
||||
|
||||
recipients + target.subscribers(project)
|
||||
end
|
||||
|
||||
def add_labels_subscribers(recipients, project, target, labels: nil)
|
||||
return recipients unless target.respond_to? :labels
|
||||
|
||||
(labels || target.labels).each do |label|
|
||||
recipients += label.subscribers(project)
|
||||
end
|
||||
|
||||
recipients
|
||||
end
|
||||
|
||||
def new_resource_email(target, project, method)
|
||||
recipients = build_recipients(target, project, target.author, action: "new")
|
||||
recipients = NotificationRecipientService.new(project).build_recipients(target, target.author, action: "new")
|
||||
|
||||
recipients.each do |recipient|
|
||||
mailer.send(method, recipient.id, target.id).deliver_later
|
||||
|
@ -537,7 +299,7 @@ class NotificationService
|
|||
end
|
||||
|
||||
def new_mentions_in_resource_email(target, project, new_mentioned_users, current_user, method)
|
||||
recipients = build_recipients(target, project, current_user, action: "new")
|
||||
recipients = NotificationRecipientService.new(project).build_recipients(target, current_user, action: "new")
|
||||
recipients = recipients & new_mentioned_users
|
||||
|
||||
recipients.each do |recipient|
|
||||
|
@ -548,9 +310,8 @@ class NotificationService
|
|||
def close_resource_email(target, project, current_user, method, skip_current_user: true)
|
||||
action = method == :merged_merge_request_email ? "merge" : "close"
|
||||
|
||||
recipients = build_recipients(
|
||||
recipients = NotificationRecipientService.new(project).build_recipients(
|
||||
target,
|
||||
project,
|
||||
current_user,
|
||||
action: action,
|
||||
skip_current_user: skip_current_user
|
||||
|
@ -565,7 +326,12 @@ class NotificationService
|
|||
previous_assignee_id = previous_record(target, 'assignee_id')
|
||||
previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id
|
||||
|
||||
recipients = build_recipients(target, project, current_user, action: "reassign", previous_assignee: previous_assignee)
|
||||
recipients = NotificationRecipientService.new(project).build_recipients(
|
||||
target,
|
||||
current_user,
|
||||
action: "reassign",
|
||||
previous_assignee: previous_assignee
|
||||
)
|
||||
|
||||
recipients.each do |recipient|
|
||||
mailer.send(
|
||||
|
@ -579,7 +345,7 @@ class NotificationService
|
|||
end
|
||||
|
||||
def relabeled_resource_email(target, project, labels, current_user, method)
|
||||
recipients = build_relabeled_recipients(target, project, current_user, labels: labels)
|
||||
recipients = NotificationRecipientService.new(project).build_relabeled_recipients(target, current_user, labels: labels)
|
||||
label_names = labels.map(&:name)
|
||||
|
||||
recipients.each do |recipient|
|
||||
|
@ -588,58 +354,13 @@ class NotificationService
|
|||
end
|
||||
|
||||
def reopen_resource_email(target, project, current_user, method, status)
|
||||
recipients = build_recipients(target, project, current_user, action: "reopen")
|
||||
recipients = NotificationRecipientService.new(project).build_recipients(target, current_user, action: "reopen")
|
||||
|
||||
recipients.each do |recipient|
|
||||
mailer.send(method, recipient.id, target.id, status, current_user.id).deliver_later
|
||||
end
|
||||
end
|
||||
|
||||
def build_recipients(target, project, current_user, action: nil, previous_assignee: nil, skip_current_user: true)
|
||||
custom_action = build_custom_key(action, target)
|
||||
|
||||
recipients = target.participants(current_user)
|
||||
|
||||
unless NotificationSetting::EXCLUDED_WATCHER_EVENTS.include?(custom_action)
|
||||
recipients = add_project_watchers(recipients, project)
|
||||
end
|
||||
|
||||
recipients = add_custom_notifications(recipients, project, custom_action)
|
||||
recipients = reject_mention_users(recipients, project)
|
||||
|
||||
recipients = recipients.uniq
|
||||
|
||||
# Re-assign is considered as a mention of the new assignee so we add the
|
||||
# new assignee to the list of recipients after we rejected users with
|
||||
# the "on mention" notification level
|
||||
if [:reassign_merge_request, :reassign_issue].include?(custom_action)
|
||||
recipients << previous_assignee if previous_assignee
|
||||
recipients << target.assignee
|
||||
end
|
||||
|
||||
recipients = reject_muted_users(recipients, project)
|
||||
recipients = add_subscribed_users(recipients, project, target)
|
||||
|
||||
if [:new_issue, :new_merge_request].include?(custom_action)
|
||||
recipients = add_labels_subscribers(recipients, project, target)
|
||||
end
|
||||
|
||||
recipients = reject_unsubscribed_users(recipients, target)
|
||||
recipients = reject_users_without_access(recipients, target)
|
||||
|
||||
recipients.delete(current_user) if skip_current_user
|
||||
|
||||
recipients.uniq
|
||||
end
|
||||
|
||||
def build_relabeled_recipients(target, project, current_user, labels:)
|
||||
recipients = add_labels_subscribers([], project, target, labels: labels)
|
||||
recipients = reject_unsubscribed_users(recipients, target)
|
||||
recipients = reject_users_without_access(recipients, target)
|
||||
recipients.delete(current_user)
|
||||
recipients.uniq
|
||||
end
|
||||
|
||||
def mailer
|
||||
Notify
|
||||
end
|
||||
|
@ -651,10 +372,4 @@ class NotificationService
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Build event key to search on custom notification level
|
||||
# Check NotificationSetting::EMAIL_EVENTS
|
||||
def build_custom_key(action, object)
|
||||
"#{action}_#{object.class.model_name.name.underscore}".to_sym
|
||||
end
|
||||
end
|
||||
|
|
|
@ -13,3 +13,7 @@
|
|||
= form.label :only_allow_merge_if_all_discussions_are_resolved do
|
||||
= form.check_box :only_allow_merge_if_all_discussions_are_resolved
|
||||
%strong Only allow merge requests to be merged if all discussions are resolved
|
||||
.checkbox
|
||||
= form.label :printing_merge_request_link_enabled do
|
||||
= form.check_box :printing_merge_request_link_enabled
|
||||
%strong Show link to create/view merge request when pushing from the command line
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
%board-blank-state{ "inline-template" => true,
|
||||
"v-if" => 'list.id == "blank"' }
|
||||
.board-blank-state
|
||||
%p
|
||||
Add the following default lists to your Issue Board with one click:
|
||||
%ul.board-blank-state-list
|
||||
%li{ "v-for" => "label in predefinedLabels" }
|
||||
%span.label-color{ ":style" => "{ backgroundColor: label.color } " }
|
||||
{{ label.title }}
|
||||
%p
|
||||
Starting out with the default set of lists will get you right on the way to making the most of your board.
|
||||
%button.btn.btn-create.btn-inverted.btn-block{ type: "button", "@click.stop" => "addDefaultLists" }
|
||||
Add default lists
|
||||
%button.btn.btn-default.btn-block{ type: "button", "@click.stop" => "clearBlankState" }
|
||||
Nevermind, I'll use my own
|
|
@ -32,4 +32,4 @@
|
|||
":root-path" => "rootPath",
|
||||
"ref" => "board-list" }
|
||||
- if can?(current_user, :admin_list, @project)
|
||||
= render "projects/boards/components/blank_state"
|
||||
%board-blank-state{ "v-if" => 'list.id == "blank"' }
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
= render 'shared/no_ssh'
|
||||
= render 'shared/no_password'
|
||||
|
||||
= render "projects/head"
|
||||
= render "home_panel"
|
||||
|
||||
.row-content-block.second-block.center
|
||||
|
|
4
changelogs/unreleased/21451-allow-disable-mr-link.yml
Normal file
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
title: Add ability to disable Merge Request URL on push
|
||||
merge_request: 9663
|
||||
author: Alex Sanford
|
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
title: Strip reference prefixes on branch creation
|
||||
merge_request: 8498
|
||||
author: Matthieu Tardy
|
4
changelogs/unreleased/29604-v3-fix-branch-creation.yml
Normal file
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
title: Use "branch_name" instead "branch" on V3 branch creation API
|
||||
merge_request:
|
||||
author:
|
4
changelogs/unreleased/add-labels-to-issue-hook.yml
Normal file
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
title: Added labels array to the issue web hook returned object
|
||||
merge_request: 9972
|
||||
author:
|
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
title: Use Gitaly for CommitController#show
|
||||
merge_request: 9629
|
||||
author:
|
4
changelogs/unreleased/fl-remove-ujs-pipelines.yml
Normal file
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
title: 'Removes UJS from pipelines tables'
|
||||
merge_request: 9929
|
||||
author:
|
|
@ -42,7 +42,7 @@ Sidekiq.configure_server do |config|
|
|||
|
||||
Gitlab::SidekiqThrottler.execute!
|
||||
|
||||
config = ActiveRecord::Base.configurations[Rails.env] ||
|
||||
config = Gitlab::Database.config ||
|
||||
Rails.application.config.database_configuration[Rails.env]
|
||||
config['pool'] = Sidekiq.options[:concurrency]
|
||||
ActiveRecord::Base.establish_connection(config)
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
|
||||
# for more information on how to write migrations for GitLab.
|
||||
|
||||
class AddPrintingMergeRequestLinkEnabledToProject < ActiveRecord::Migration
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
disable_ddl_transaction!
|
||||
|
||||
# Set this constant to true if this migration requires downtime.
|
||||
DOWNTIME = false
|
||||
|
||||
def up
|
||||
add_column_with_default(:projects, :printing_merge_request_link_enabled, :boolean, default: true)
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column(:projects, :printing_merge_request_link_enabled)
|
||||
end
|
||||
end
|
|
@ -1003,6 +1003,7 @@ ActiveRecord::Schema.define(version: 20170315174634) do
|
|||
t.boolean "lfs_enabled"
|
||||
t.text "description_html"
|
||||
t.boolean "only_allow_merge_if_all_discussions_are_resolved"
|
||||
t.boolean "printing_merge_request_link_enabled", default: true, null: false
|
||||
end
|
||||
|
||||
add_index "projects", ["ci_id"], name: "index_projects_on_ci_id", using: :btree
|
||||
|
|
|
@ -36,16 +36,23 @@ You can find documentation about the desired architecture for a new feature buil
|
|||
|
||||
When writing code for realtime features we have to keep a couple of things in mind:
|
||||
1. Do not overload the server with requests.
|
||||
1. It should feel realtime.
|
||||
1. It should feel realtime.
|
||||
|
||||
Thus, we must strike a balance between sending requests and the feeling of realtime. Use the following rules when creating realtime solutions.
|
||||
Thus, we must strike a balance between sending requests and the feeling of realtime.
|
||||
Use the following rules when creating realtime solutions.
|
||||
|
||||
1. The server will tell you how much to poll by sending `X-Poll-Interval` in the header. Use that as your polling interval. This way it is easy for system administrators to change the polling rate. A `X-Poll-Interval: -1` means you should disable polling, and this must be implemented.
|
||||
1. A response of `HTTP 429 Too Many Requests`, should disable polling as well. This must also be implemented.
|
||||
1. The server will tell you how much to poll by sending `Poll-Interval` in the header.
|
||||
Use that as your polling interval. This way it is easy for system administrators to change the
|
||||
polling rate.
|
||||
A `Poll-Interval: -1` means you should disable polling, and this must be implemented.
|
||||
1. A response with HTTP status `4XX` or `5XX` should disable polling as well.
|
||||
1. Use a common library for polling.
|
||||
1. Poll on active tabs only. Use a common library to find out which tab currently has eyes on it. Please use [Focus](https://gitlab.com/andrewn/focus). Specifically [Eyeballs Detector](https://gitlab.com/andrewn/focus/blob/master/lib/eyeballs-detector.js).
|
||||
1. Use regular polling intervals, do not use backoff polling, or jitter, as the interval will be controlled by the server.
|
||||
1. The backend code will most likely be using etags. You do not and should not check for status `304 Not Modified`. The browser will transform it for you.
|
||||
1. Poll on active tabs only. Use a common library to find out which tab currently has eyes on it.
|
||||
Please use [Focus](https://gitlab.com/andrewn/focus). Specifically [Eyeballs Detector](https://gitlab.com/andrewn/focus/blob/master/lib/eyeballs-detector.js).
|
||||
1. Use regular polling intervals, do not use backoff polling, or jitter, as the interval will be
|
||||
controlled by the server.
|
||||
1. The backend code will most likely be using etags. You do not and should not check for status
|
||||
`304 Not Modified`. The browser will transform it for you.
|
||||
|
||||
### Vue
|
||||
|
||||
|
|
51
doc/user/award_emojis.md
Normal file
|
@ -0,0 +1,51 @@
|
|||
# Award emoji
|
||||
|
||||
>**Notes:**
|
||||
- First [introduced][1825] in GitLab 8.2.
|
||||
- GitLab 9.0 [introduced][ce-9570] the usage of native emojis if the platform
|
||||
supports them and falls back to images or CSS sprites. This change greatly
|
||||
improved the award emoji performance overall.
|
||||
|
||||
When you're collaborating online, you get fewer opportunities for high-fives
|
||||
and thumbs-ups. Emoji can be awarded to issues, merge requests, snippets, and
|
||||
virtually everywhere where you can have a discussion.
|
||||
|
||||
![Award emoji](img/award_emoji_select.png)
|
||||
|
||||
Award emoji make it much easier to give and receive feedback without a long
|
||||
comment thread. Comments that are only emoji will automatically become
|
||||
award emoji.
|
||||
|
||||
## Sort issues and merge requests on vote count
|
||||
|
||||
> [Introduced][2871] in GitLab 8.5.
|
||||
|
||||
You can quickly sort issues and merge requests by the number of votes they
|
||||
have received. The sort options can be found in the dropdown menu as "Most
|
||||
popular" and "Least popular".
|
||||
|
||||
![Votes sort options](img/award_emoji_votes_sort_options.png)
|
||||
|
||||
The total number of votes is not summed up. An issue with 18 upvotes and 5
|
||||
downvotes is considered more popular than an issue with 17 upvotes and no
|
||||
downvotes.
|
||||
|
||||
## Award emoji for comments
|
||||
|
||||
> [Introduced][4291] in GitLab 8.9.
|
||||
|
||||
Award emoji can also be applied to individual comments when you want to
|
||||
celebrate an accomplishment or agree with an opinion.
|
||||
|
||||
To add an award emoji, click the smile in the top right of the comment and pick
|
||||
an emoji from the dropdown. If you want to remove an award emoji, just click
|
||||
the emoji again and the vote will be removed.
|
||||
|
||||
![Picking an emoji for a comment](img/award_emoji_comment_picker.png)
|
||||
|
||||
![An award emoji has been applied to a comment](img/award_emoji_comment_awarded.png)
|
||||
|
||||
[2871]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/2781
|
||||
[1825]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/1825
|
||||
[4291]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4291
|
||||
[ce-9570]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9570
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
BIN
doc/user/img/award_emoji_select.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
doc/user/img/award_emoji_votes_sort_options.png
Normal file
After Width: | Height: | Size: 98 KiB |
|
@ -250,7 +250,19 @@ X-Gitlab-Event: Issue Hook
|
|||
"name": "User1",
|
||||
"username": "user1",
|
||||
"avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
|
||||
}
|
||||
},
|
||||
"labels": [{
|
||||
"id": 206,
|
||||
"title": "API",
|
||||
"color": "#ffffff",
|
||||
"project_id": 14,
|
||||
"created_at": "2013-12-03T17:15:43Z",
|
||||
"updated_at": "2013-12-03T17:15:43Z",
|
||||
"template": false,
|
||||
"description": "API related issues",
|
||||
"type": "ProjectLabel",
|
||||
"group_id": 41
|
||||
}]
|
||||
}
|
||||
```
|
||||
### Comment events
|
||||
|
|
|
@ -1,65 +1 @@
|
|||
# Award emoji
|
||||
|
||||
>**Note:**
|
||||
[Introduced][1825] in GitLab 8.2.
|
||||
|
||||
When you're collaborating online, you get fewer opportunities for high-fives
|
||||
and thumbs-ups. Emoji can be awarded to issues and merge requests, making
|
||||
virtual celebrations easier.
|
||||
|
||||
![Award emoji](img/award_emoji_select.png)
|
||||
|
||||
Award emoji make it much easier to give and receive feedback without a long
|
||||
comment thread. Comments that are only emoji will automatically become
|
||||
award emoji.
|
||||
|
||||
## Sort issues and merge requests on vote count
|
||||
|
||||
>**Note:**
|
||||
[Introduced][2871] in GitLab 8.5.
|
||||
|
||||
You can quickly sort issues and merge requests by the number of votes they
|
||||
have received. The sort options can be found in the dropdown menu as "Most
|
||||
popular" and "Least popular".
|
||||
|
||||
![Votes sort options](img/award_emoji_votes_sort_options.png)
|
||||
|
||||
---
|
||||
|
||||
Sort by most popular issues/merge requests.
|
||||
|
||||
![Votes sort by most popular](img/award_emoji_votes_most_popular.png)
|
||||
|
||||
---
|
||||
|
||||
Sort by least popular issues/merge requests.
|
||||
|
||||
![Votes sort by least popular](img/award_emoji_votes_least_popular.png)
|
||||
|
||||
---
|
||||
|
||||
The total number of votes is not summed up. An issue with 18 upvotes and 5
|
||||
downvotes is considered more popular than an issue with 17 upvotes and no
|
||||
downvotes.
|
||||
|
||||
## Award emoji for comments
|
||||
|
||||
>**Note:**
|
||||
[Introduced][4291] in GitLab 8.9.
|
||||
|
||||
Award emoji can also be applied to individual comments when you want to
|
||||
celebrate an accomplishment or agree with an opinion.
|
||||
|
||||
To add an award emoji, click the smile in the top right of the comment and pick
|
||||
an emoji from the dropdown.
|
||||
|
||||
![Picking an emoji for a comment](img/award_emoji_comment_picker.png)
|
||||
|
||||
![An award emoji has been applied to a comment](img/award_emoji_comment_awarded.png)
|
||||
|
||||
If you want to remove an award emoji, just click the emoji again and the vote
|
||||
will be removed.
|
||||
|
||||
[2871]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/2781
|
||||
[1825]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/1825
|
||||
[4291]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4291
|
||||
This document was moved to [another location](../user/award_emojis.md).
|
||||
|
|
Before Width: | Height: | Size: 23 KiB |
Before Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 56 KiB |
|
@ -45,6 +45,27 @@ module API
|
|||
|
||||
status(200)
|
||||
end
|
||||
|
||||
desc 'Create branch' do
|
||||
success ::API::Entities::RepoBranch
|
||||
end
|
||||
params do
|
||||
requires :branch_name, type: String, desc: 'The name of the branch'
|
||||
requires :ref, type: String, desc: 'Create branch from commit sha or existing branch'
|
||||
end
|
||||
post ":id/repository/branches" do
|
||||
authorize_push_project
|
||||
result = CreateBranchService.new(user_project, current_user).
|
||||
execute(params[:branch_name], params[:ref])
|
||||
|
||||
if result[:status] == :success
|
||||
present result[:branch],
|
||||
with: ::API::Entities::RepoBranch,
|
||||
project: user_project
|
||||
else
|
||||
render_api_error!(result[:message], 400)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,8 +5,12 @@ module Gitlab
|
|||
# http://dev.mysql.com/doc/refman/5.7/en/integer-types.html
|
||||
MAX_INT_VALUE = 2147483647
|
||||
|
||||
def self.config
|
||||
ActiveRecord::Base.configurations[Rails.env]
|
||||
end
|
||||
|
||||
def self.adapter_name
|
||||
ActiveRecord::Base.configurations[Rails.env]['adapter']
|
||||
config['adapter']
|
||||
end
|
||||
|
||||
def self.mysql?
|
||||
|
|
|
@ -176,9 +176,13 @@ module Gitlab
|
|||
def initialize(raw_diff, collapse: false)
|
||||
case raw_diff
|
||||
when Hash
|
||||
init_from_hash(raw_diff, collapse: collapse)
|
||||
init_from_hash(raw_diff)
|
||||
prune_diff_if_eligible(collapse)
|
||||
when Rugged::Patch, Rugged::Diff::Delta
|
||||
init_from_rugged(raw_diff, collapse: collapse)
|
||||
when Gitaly::CommitDiffResponse
|
||||
init_from_gitaly(raw_diff)
|
||||
prune_diff_if_eligible(collapse)
|
||||
when nil
|
||||
raise "Nil as raw diff passed"
|
||||
else
|
||||
|
@ -266,13 +270,26 @@ module Gitlab
|
|||
@diff = encode!(strip_diff_headers(patch.to_s))
|
||||
end
|
||||
|
||||
def init_from_hash(hash, collapse: false)
|
||||
def init_from_hash(hash)
|
||||
raw_diff = hash.symbolize_keys
|
||||
|
||||
serialize_keys.each do |key|
|
||||
send(:"#{key}=", raw_diff[key.to_sym])
|
||||
end
|
||||
end
|
||||
|
||||
def init_from_gitaly(diff_msg)
|
||||
@diff = diff_msg.raw_chunks.join
|
||||
@new_path = encode!(diff_msg.to_path.dup)
|
||||
@old_path = encode!(diff_msg.from_path.dup)
|
||||
@a_mode = diff_msg.old_mode.to_s(8)
|
||||
@b_mode = diff_msg.new_mode.to_s(8)
|
||||
@new_file = diff_msg.from_id == BLANK_SHA
|
||||
@renamed_file = diff_msg.from_path != diff_msg.to_path
|
||||
@deleted_file = diff_msg.to_id == BLANK_SHA
|
||||
end
|
||||
|
||||
def prune_diff_if_eligible(collapse = false)
|
||||
prune_large_diff! if too_large?
|
||||
prune_collapsed_diff! if collapse && collapsible?
|
||||
end
|
||||
|
|
|
@ -30,7 +30,9 @@ module Gitlab
|
|||
elsif @deltas_only
|
||||
each_delta(&block)
|
||||
else
|
||||
each_patch(&block)
|
||||
Gitlab::GitalyClient.migrate(:commit_raw_diffs) do
|
||||
each_patch(&block)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -5,6 +5,9 @@ module Gitlab
|
|||
#
|
||||
# Returns true for a valid reference name, false otherwise
|
||||
def validate(ref_name)
|
||||
return false if ref_name.start_with?('refs/heads/')
|
||||
return false if ref_name.start_with?('refs/remotes/')
|
||||
|
||||
Gitlab::Utils.system_silent(
|
||||
%W(#{Gitlab.config.git.bin_path} check-ref-format refs/#{ref_name}))
|
||||
end
|
||||
|
|
|
@ -25,5 +25,19 @@ module Gitlab
|
|||
def self.enabled?
|
||||
gitaly_address.present?
|
||||
end
|
||||
|
||||
def self.feature_enabled?(feature)
|
||||
enabled? && ENV["GITALY_#{feature.upcase}"] == '1'
|
||||
end
|
||||
|
||||
def self.migrate(feature)
|
||||
is_enabled = feature_enabled?(feature)
|
||||
metric_name = feature.to_s
|
||||
metric_name += "_gitaly" if is_enabled
|
||||
|
||||
Gitlab::Metrics.measure(metric_name) do
|
||||
yield is_enabled
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
25
lib/gitlab/gitaly_client/commit.rb
Normal file
|
@ -0,0 +1,25 @@
|
|||
module Gitlab
|
||||
module GitalyClient
|
||||
class Commit
|
||||
# The ID of empty tree.
|
||||
# See http://stackoverflow.com/a/40884093/1856239 and https://github.com/git/git/blob/3ad8b5bf26362ac67c9020bf8c30eee54a84f56d/cache.h#L1011-L1012
|
||||
EMPTY_TREE_ID = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'.freeze
|
||||
|
||||
class << self
|
||||
def diff_from_parent(commit, options = {})
|
||||
stub = Gitaly::Diff::Stub.new(nil, nil, channel_override: GitalyClient.channel)
|
||||
repo = Gitaly::Repository.new(path: commit.project.repository.path_to_repo)
|
||||
parent = commit.parents[0]
|
||||
parent_id = parent ? parent.id : EMPTY_TREE_ID
|
||||
request = Gitaly::CommitDiffRequest.new(
|
||||
repository: repo,
|
||||
left_commit_id: parent_id,
|
||||
right_commit_id: commit.id
|
||||
)
|
||||
|
||||
Gitlab::Git::DiffCollection.new(stub.commit_diff(request), options)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -5,8 +5,14 @@ module QA
|
|||
def initialize
|
||||
visit('/')
|
||||
|
||||
# This resolves cold boot problems with login page
|
||||
find('.application', wait: 120)
|
||||
# This resolves cold boot / background tasks problems
|
||||
#
|
||||
start = Time.now
|
||||
|
||||
while Time.now - start < 240
|
||||
break if page.has_css?('.application', wait: 10)
|
||||
refresh
|
||||
end
|
||||
end
|
||||
|
||||
def sign_in_using_credentials
|
||||
|
|
|
@ -60,9 +60,6 @@ feature 'Merge request created from fork' do
|
|||
expect(page).to have_content pipeline.status
|
||||
expect(page).to have_content pipeline.id
|
||||
end
|
||||
|
||||
expect(page.find('a.btn-remove')[:href])
|
||||
.to include fork_project.path_with_namespace
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -99,15 +99,18 @@ describe 'Pipelines', :feature, :js do
|
|||
end
|
||||
|
||||
it 'indicates that pipeline can be canceled' do
|
||||
expect(page).to have_link('Cancel')
|
||||
expect(page).to have_selector('.js-pipelines-cancel-button')
|
||||
expect(page).to have_selector('.ci-running')
|
||||
end
|
||||
|
||||
context 'when canceling' do
|
||||
before { click_link('Cancel') }
|
||||
before do
|
||||
find('.js-pipelines-cancel-button').click
|
||||
wait_for_vue_resource
|
||||
end
|
||||
|
||||
it 'indicated that pipelines was canceled' do
|
||||
expect(page).not_to have_link('Cancel')
|
||||
expect(page).not_to have_selector('.js-pipelines-cancel-button')
|
||||
expect(page).to have_selector('.ci-canceled')
|
||||
end
|
||||
end
|
||||
|
@ -126,15 +129,18 @@ describe 'Pipelines', :feature, :js do
|
|||
end
|
||||
|
||||
it 'indicates that pipeline can be retried' do
|
||||
expect(page).to have_link('Retry')
|
||||
expect(page).to have_selector('.js-pipelines-retry-button')
|
||||
expect(page).to have_selector('.ci-failed')
|
||||
end
|
||||
|
||||
context 'when retrying' do
|
||||
before { click_link('Retry') }
|
||||
before do
|
||||
find('.js-pipelines-retry-button').click
|
||||
wait_for_vue_resource
|
||||
end
|
||||
|
||||
it 'shows running pipeline that is not retryable' do
|
||||
expect(page).not_to have_link('Retry')
|
||||
expect(page).not_to have_selector('.js-pipelines-retry-button')
|
||||
expect(page).to have_selector('.ci-running')
|
||||
end
|
||||
end
|
||||
|
@ -176,17 +182,17 @@ describe 'Pipelines', :feature, :js do
|
|||
it 'has link to the manual action' do
|
||||
find('.js-pipeline-dropdown-manual-actions').click
|
||||
|
||||
expect(page).to have_link('manual build')
|
||||
expect(page).to have_button('manual build')
|
||||
end
|
||||
|
||||
context 'when manual action was played' do
|
||||
before do
|
||||
find('.js-pipeline-dropdown-manual-actions').click
|
||||
click_link('manual build')
|
||||
click_button('manual build')
|
||||
end
|
||||
|
||||
it 'enqueues manual action job' do
|
||||
expect(manual.reload).to be_pending
|
||||
expect(page).to have_selector('.js-pipeline-dropdown-manual-actions:disabled')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -203,7 +209,7 @@ describe 'Pipelines', :feature, :js do
|
|||
before { visit_project_pipelines }
|
||||
|
||||
it 'is cancelable' do
|
||||
expect(page).to have_link('Cancel')
|
||||
expect(page).to have_selector('.js-pipelines-cancel-button')
|
||||
end
|
||||
|
||||
it 'has pipeline running' do
|
||||
|
@ -211,10 +217,10 @@ describe 'Pipelines', :feature, :js do
|
|||
end
|
||||
|
||||
context 'when canceling' do
|
||||
before { click_link('Cancel') }
|
||||
before { find('.js-pipelines-cancel-button').trigger('click') }
|
||||
|
||||
it 'indicates that pipeline was canceled' do
|
||||
expect(page).not_to have_link('Cancel')
|
||||
expect(page).not_to have_selector('.js-pipelines-cancel-button')
|
||||
expect(page).to have_selector('.ci-canceled')
|
||||
end
|
||||
end
|
||||
|
@ -233,7 +239,7 @@ describe 'Pipelines', :feature, :js do
|
|||
end
|
||||
|
||||
it 'is not retryable' do
|
||||
expect(page).not_to have_link('Retry')
|
||||
expect(page).not_to have_selector('.js-pipelines-retry-button')
|
||||
end
|
||||
|
||||
it 'has failed pipeline' do
|
||||
|
|
|
@ -62,4 +62,27 @@ feature 'Project settings > Merge Requests', feature: true, js: true do
|
|||
expect(page).to have_content('Only allow merge requests to be merged if all discussions are resolved')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Checkbox to enable merge request link' do
|
||||
before do
|
||||
visit edit_project_path(project)
|
||||
end
|
||||
|
||||
scenario 'is initially checked' do
|
||||
checkbox = find_field('project_printing_merge_request_link_enabled')
|
||||
expect(checkbox).to be_checked
|
||||
end
|
||||
|
||||
scenario 'when unchecked sets :printing_merge_request_link_enabled to false' do
|
||||
uncheck('project_printing_merge_request_link_enabled')
|
||||
click_on('Save')
|
||||
|
||||
# Wait for save to complete and page to reload
|
||||
checkbox = find_field('project_printing_merge_request_link_enabled')
|
||||
expect(checkbox).not_to be_checked
|
||||
|
||||
project.reload
|
||||
expect(project.printing_merge_request_link_enabled).to be(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -49,16 +49,20 @@ describe MilestonesHelper do
|
|||
end
|
||||
|
||||
describe '#milestone_remaining_days' do
|
||||
around do |example|
|
||||
Timecop.freeze(Time.utc(2017, 3, 17)) { example.run }
|
||||
end
|
||||
|
||||
context 'when less than 31 days remaining' do
|
||||
let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, due_date: 12.days.from_now)) }
|
||||
let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, due_date: 12.days.from_now.utc)) }
|
||||
|
||||
it 'returns days remaining' do
|
||||
expect(milestone_remaining).to eq("<strong>11</strong> days remaining")
|
||||
expect(milestone_remaining).to eq("<strong>12</strong> days remaining")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when less than 1 year and more than 30 days remaining' do
|
||||
let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, due_date: 2.months.from_now)) }
|
||||
let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, due_date: 2.months.from_now.utc)) }
|
||||
|
||||
it 'returns months remaining' do
|
||||
expect(milestone_remaining).to eq("<strong>2</strong> months remaining")
|
||||
|
@ -66,7 +70,7 @@ describe MilestonesHelper do
|
|||
end
|
||||
|
||||
context 'when more than 1 year remaining' do
|
||||
let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, due_date: 1.year.from_now + 2.days)) }
|
||||
let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, due_date: (1.year.from_now + 2.days).utc)) }
|
||||
|
||||
it 'returns years remaining' do
|
||||
expect(milestone_remaining).to eq("<strong>1</strong> year remaining")
|
||||
|
@ -74,7 +78,7 @@ describe MilestonesHelper do
|
|||
end
|
||||
|
||||
context 'when milestone is expired' do
|
||||
let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, due_date: 2.days.ago)) }
|
||||
let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, due_date: 2.days.ago.utc)) }
|
||||
|
||||
it 'returns "Past due"' do
|
||||
expect(milestone_remaining).to eq("<strong>Past due</strong>")
|
||||
|
@ -82,7 +86,7 @@ describe MilestonesHelper do
|
|||
end
|
||||
|
||||
context 'when milestone has start_date in the future' do
|
||||
let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, start_date: 2.days.from_now)) }
|
||||
let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, start_date: 2.days.from_now.utc)) }
|
||||
|
||||
it 'returns "Upcoming"' do
|
||||
expect(milestone_remaining).to eq("<strong>Upcoming</strong>")
|
||||
|
@ -90,7 +94,7 @@ describe MilestonesHelper do
|
|||
end
|
||||
|
||||
context 'when milestone has start_date in the past' do
|
||||
let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, start_date: 2.days.ago)) }
|
||||
let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, start_date: 2.days.ago.utc)) }
|
||||
|
||||
it 'returns days elapsed' do
|
||||
expect(milestone_remaining).to eq("<strong>2</strong> days elapsed")
|
||||
|
|
93
spec/javascripts/boards/board_blank_state_spec.js
Normal file
|
@ -0,0 +1,93 @@
|
|||
/* global BoardService */
|
||||
import Vue from 'vue';
|
||||
import '~/boards/stores/boards_store';
|
||||
import boardBlankState from '~/boards/components/board_blank_state';
|
||||
import './mock_data';
|
||||
|
||||
describe('Boards blank state', () => {
|
||||
let vm;
|
||||
let fail = false;
|
||||
|
||||
beforeEach((done) => {
|
||||
const Comp = Vue.extend(boardBlankState);
|
||||
|
||||
gl.issueBoards.BoardsStore.create();
|
||||
gl.boardService = new BoardService('/test/issue-boards/board', '', '1');
|
||||
|
||||
spyOn(gl.boardService, 'generateDefaultLists').and.callFake(() => new Promise((resolve, reject) => {
|
||||
if (fail) {
|
||||
reject();
|
||||
} else {
|
||||
resolve({
|
||||
json() {
|
||||
return [{
|
||||
id: 1,
|
||||
title: 'To Do',
|
||||
label: { id: 1 },
|
||||
}, {
|
||||
id: 2,
|
||||
title: 'Doing',
|
||||
label: { id: 2 },
|
||||
}];
|
||||
},
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
vm = new Comp();
|
||||
|
||||
setTimeout(() => {
|
||||
vm.$mount();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders pre-defined labels', () => {
|
||||
expect(
|
||||
vm.$el.querySelectorAll('.board-blank-state-list li').length,
|
||||
).toBe(2);
|
||||
|
||||
expect(
|
||||
vm.$el.querySelectorAll('.board-blank-state-list li')[0].textContent.trim(),
|
||||
).toEqual('To Do');
|
||||
|
||||
expect(
|
||||
vm.$el.querySelectorAll('.board-blank-state-list li')[1].textContent.trim(),
|
||||
).toEqual('Doing');
|
||||
});
|
||||
|
||||
it('clears blank state', (done) => {
|
||||
vm.$el.querySelector('.btn-default').click();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(gl.issueBoards.BoardsStore.welcomeIsHidden()).toBeTruthy();
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('creates pre-defined labels', (done) => {
|
||||
vm.$el.querySelector('.btn-create').click();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(2);
|
||||
expect(gl.issueBoards.BoardsStore.state.lists[0].title).toEqual('To Do');
|
||||
expect(gl.issueBoards.BoardsStore.state.lists[1].title).toEqual('Doing');
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('resets the store if request fails', (done) => {
|
||||
fail = true;
|
||||
|
||||
vm.$el.querySelector('.btn-create').click();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(gl.issueBoards.BoardsStore.welcomeIsHidden()).toBeFalsy();
|
||||
expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1);
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,5 +1,4 @@
|
|||
/* eslint-disable no-unused-vars */
|
||||
const pipeline = {
|
||||
export default {
|
||||
id: 73,
|
||||
user: {
|
||||
name: 'Administrator',
|
||||
|
@ -88,5 +87,3 @@ const pipeline = {
|
|||
created_at: '2017-01-16T17:13:59.800Z',
|
||||
updated_at: '2017-01-25T00:00:17.132Z',
|
||||
};
|
||||
|
||||
module.exports = pipeline;
|
||||
|
|
|
@ -1,11 +1,6 @@
|
|||
/* global pipeline, Vue */
|
||||
|
||||
require('~/flash');
|
||||
require('~/commit/pipelines/pipelines_store');
|
||||
require('~/commit/pipelines/pipelines_service');
|
||||
require('~/commit/pipelines/pipelines_table');
|
||||
require('~/vue_shared/vue_resource_interceptor');
|
||||
const pipeline = require('./mock_data');
|
||||
import Vue from 'vue';
|
||||
import PipelinesTable from '~/commit/pipelines/pipelines_table';
|
||||
import pipeline from './mock_data';
|
||||
|
||||
describe('Pipelines table in Commits and Merge requests', () => {
|
||||
preloadFixtures('static/pipelines_table.html.raw');
|
||||
|
@ -33,7 +28,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
|
|||
});
|
||||
|
||||
it('should render the empty state', (done) => {
|
||||
const component = new gl.commits.pipelines.PipelinesTableView({
|
||||
const component = new PipelinesTable({
|
||||
el: document.querySelector('#commit-pipeline-table-view'),
|
||||
});
|
||||
|
||||
|
@ -62,7 +57,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
|
|||
});
|
||||
|
||||
it('should render a table with the received pipelines', (done) => {
|
||||
const component = new gl.commits.pipelines.PipelinesTableView({
|
||||
const component = new PipelinesTable({
|
||||
el: document.querySelector('#commit-pipeline-table-view'),
|
||||
});
|
||||
|
||||
|
@ -92,7 +87,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
|
|||
});
|
||||
|
||||
it('should render empty state', (done) => {
|
||||
const component = new gl.commits.pipelines.PipelinesTableView({
|
||||
const component = new PipelinesTable({
|
||||
el: document.querySelector('#commit-pipeline-table-view'),
|
||||
});
|
||||
|
||||
|
|
|
@ -1,33 +0,0 @@
|
|||
const PipelinesStore = require('~/commit/pipelines/pipelines_store');
|
||||
|
||||
describe('Store', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new PipelinesStore();
|
||||
});
|
||||
|
||||
// unregister intervals and event handlers
|
||||
afterEach(() => gl.VueRealtimeListener.reset());
|
||||
|
||||
it('should start with a blank state', () => {
|
||||
expect(store.state.pipelines.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should store an array of pipelines', () => {
|
||||
const pipelines = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'pipeline',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'pipeline_2',
|
||||
},
|
||||
];
|
||||
|
||||
store.storePipelines(pipelines);
|
||||
|
||||
expect(store.state.pipelines.length).toBe(pipelines.length);
|
||||
});
|
||||
});
|
93
spec/javascripts/vue_pipelines_index/async_button_spec.js
Normal file
|
@ -0,0 +1,93 @@
|
|||
import Vue from 'vue';
|
||||
import asyncButtonComp from '~/vue_pipelines_index/components/async_button';
|
||||
|
||||
describe('Pipelines Async Button', () => {
|
||||
let component;
|
||||
let spy;
|
||||
let AsyncButtonComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
AsyncButtonComponent = Vue.extend(asyncButtonComp);
|
||||
|
||||
spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve());
|
||||
|
||||
component = new AsyncButtonComponent({
|
||||
propsData: {
|
||||
endpoint: '/foo',
|
||||
title: 'Foo',
|
||||
icon: 'fa fa-foo',
|
||||
cssClass: 'bar',
|
||||
service: {
|
||||
postAction: spy,
|
||||
},
|
||||
},
|
||||
}).$mount();
|
||||
});
|
||||
|
||||
it('should render a button', () => {
|
||||
expect(component.$el.tagName).toEqual('BUTTON');
|
||||
});
|
||||
|
||||
it('should render the provided icon', () => {
|
||||
expect(component.$el.querySelector('i').getAttribute('class')).toContain('fa fa-foo');
|
||||
});
|
||||
|
||||
it('should render the provided title', () => {
|
||||
expect(component.$el.getAttribute('title')).toContain('Foo');
|
||||
expect(component.$el.getAttribute('aria-label')).toContain('Foo');
|
||||
});
|
||||
|
||||
it('should render the provided cssClass', () => {
|
||||
expect(component.$el.getAttribute('class')).toContain('bar');
|
||||
});
|
||||
|
||||
it('should call the service when it is clicked with the provided endpoint', () => {
|
||||
component.$el.click();
|
||||
expect(spy).toHaveBeenCalledWith('/foo');
|
||||
});
|
||||
|
||||
it('should hide loading if request fails', () => {
|
||||
spy = jasmine.createSpy('spy').and.returnValue(Promise.reject());
|
||||
|
||||
component = new AsyncButtonComponent({
|
||||
propsData: {
|
||||
endpoint: '/foo',
|
||||
title: 'Foo',
|
||||
icon: 'fa fa-foo',
|
||||
cssClass: 'bar',
|
||||
dataAttributes: {
|
||||
'data-foo': 'foo',
|
||||
},
|
||||
service: {
|
||||
postAction: spy,
|
||||
},
|
||||
},
|
||||
}).$mount();
|
||||
|
||||
component.$el.click();
|
||||
expect(component.$el.querySelector('.fa-spinner')).toBe(null);
|
||||
});
|
||||
|
||||
describe('With confirm dialog', () => {
|
||||
it('should call the service when confimation is positive', () => {
|
||||
spyOn(window, 'confirm').and.returnValue(true);
|
||||
spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve());
|
||||
|
||||
component = new AsyncButtonComponent({
|
||||
propsData: {
|
||||
endpoint: '/foo',
|
||||
title: 'Foo',
|
||||
icon: 'fa fa-foo',
|
||||
cssClass: 'bar',
|
||||
service: {
|
||||
postAction: spy,
|
||||
},
|
||||
confirmActionMessage: 'bar',
|
||||
},
|
||||
}).$mount();
|
||||
|
||||
component.$el.click();
|
||||
expect(spy).toHaveBeenCalledWith('/foo');
|
||||
});
|
||||
});
|
||||
});
|
100
spec/javascripts/vue_pipelines_index/pipeline_url_spec.js
Normal file
|
@ -0,0 +1,100 @@
|
|||
import Vue from 'vue';
|
||||
import pipelineUrlComp from '~/vue_pipelines_index/components/pipeline_url';
|
||||
|
||||
describe('Pipeline Url Component', () => {
|
||||
let PipelineUrlComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
PipelineUrlComponent = Vue.extend(pipelineUrlComp);
|
||||
});
|
||||
|
||||
it('should render a table cell', () => {
|
||||
const component = new PipelineUrlComponent({
|
||||
propsData: {
|
||||
pipeline: {
|
||||
id: 1,
|
||||
path: 'foo',
|
||||
flags: {},
|
||||
},
|
||||
},
|
||||
}).$mount();
|
||||
|
||||
expect(component.$el.tagName).toEqual('TD');
|
||||
});
|
||||
|
||||
it('should render a link the provided path and id', () => {
|
||||
const component = new PipelineUrlComponent({
|
||||
propsData: {
|
||||
pipeline: {
|
||||
id: 1,
|
||||
path: 'foo',
|
||||
flags: {},
|
||||
},
|
||||
},
|
||||
}).$mount();
|
||||
|
||||
expect(component.$el.querySelector('.js-pipeline-url-link').getAttribute('href')).toEqual('foo');
|
||||
expect(component.$el.querySelector('.js-pipeline-url-link span').textContent).toEqual('#1');
|
||||
});
|
||||
|
||||
it('should render user information when a user is provided', () => {
|
||||
const mockData = {
|
||||
pipeline: {
|
||||
id: 1,
|
||||
path: 'foo',
|
||||
flags: {},
|
||||
user: {
|
||||
web_url: '/',
|
||||
name: 'foo',
|
||||
avatar_url: '/',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const component = new PipelineUrlComponent({
|
||||
propsData: mockData,
|
||||
}).$mount();
|
||||
|
||||
const image = component.$el.querySelector('.js-pipeline-url-user img');
|
||||
|
||||
expect(
|
||||
component.$el.querySelector('.js-pipeline-url-user').getAttribute('href'),
|
||||
).toEqual(mockData.pipeline.user.web_url);
|
||||
expect(image.getAttribute('title')).toEqual(mockData.pipeline.user.name);
|
||||
expect(image.getAttribute('src')).toEqual(mockData.pipeline.user.avatar_url);
|
||||
});
|
||||
|
||||
it('should render "API" when no user is provided', () => {
|
||||
const component = new PipelineUrlComponent({
|
||||
propsData: {
|
||||
pipeline: {
|
||||
id: 1,
|
||||
path: 'foo',
|
||||
flags: {},
|
||||
},
|
||||
},
|
||||
}).$mount();
|
||||
|
||||
expect(component.$el.querySelector('.js-pipeline-url-api').textContent).toContain('API');
|
||||
});
|
||||
|
||||
it('should render latest, yaml invalid and stuck flags when provided', () => {
|
||||
const component = new PipelineUrlComponent({
|
||||
propsData: {
|
||||
pipeline: {
|
||||
id: 1,
|
||||
path: 'foo',
|
||||
flags: {
|
||||
latest: true,
|
||||
yaml_errors: true,
|
||||
stuck: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}).$mount();
|
||||
|
||||
expect(component.$el.querySelector('.js-pipeline-url-lastest').textContent).toContain('latest');
|
||||
expect(component.$el.querySelector('.js-pipeline-url-yaml').textContent).toContain('yaml invalid');
|
||||
expect(component.$el.querySelector('.js-pipeline-url-stuck').textContent).toContain('stuck');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,62 @@
|
|||
import Vue from 'vue';
|
||||
import pipelinesActionsComp from '~/vue_pipelines_index/components/pipelines_actions';
|
||||
|
||||
describe('Pipelines Actions dropdown', () => {
|
||||
let component;
|
||||
let spy;
|
||||
let actions;
|
||||
let ActionsComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
ActionsComponent = Vue.extend(pipelinesActionsComp);
|
||||
|
||||
actions = [
|
||||
{
|
||||
name: 'stop_review',
|
||||
path: '/root/review-app/builds/1893/play',
|
||||
},
|
||||
];
|
||||
|
||||
spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve());
|
||||
|
||||
component = new ActionsComponent({
|
||||
propsData: {
|
||||
actions,
|
||||
service: {
|
||||
postAction: spy,
|
||||
},
|
||||
},
|
||||
}).$mount();
|
||||
});
|
||||
|
||||
it('should render a dropdown with the provided actions', () => {
|
||||
expect(
|
||||
component.$el.querySelectorAll('.dropdown-menu li').length,
|
||||
).toEqual(actions.length);
|
||||
});
|
||||
|
||||
it('should call the service when an action is clicked', () => {
|
||||
component.$el.querySelector('.js-pipeline-dropdown-manual-actions').click();
|
||||
component.$el.querySelector('.js-pipeline-action-link').click();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(actions[0].path);
|
||||
});
|
||||
|
||||
it('should hide loading if request fails', () => {
|
||||
spy = jasmine.createSpy('spy').and.returnValue(Promise.reject());
|
||||
|
||||
component = new ActionsComponent({
|
||||
propsData: {
|
||||
actions,
|
||||
service: {
|
||||
postAction: spy,
|
||||
},
|
||||
},
|
||||
}).$mount();
|
||||
|
||||
component.$el.querySelector('.js-pipeline-dropdown-manual-actions').click();
|
||||
component.$el.querySelector('.js-pipeline-action-link').click();
|
||||
|
||||
expect(component.$el.querySelector('.fa-spinner')).toEqual(null);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,40 @@
|
|||
import Vue from 'vue';
|
||||
import artifactsComp from '~/vue_pipelines_index/components/pipelines_artifacts';
|
||||
|
||||
describe('Pipelines Artifacts dropdown', () => {
|
||||
let component;
|
||||
let artifacts;
|
||||
|
||||
beforeEach(() => {
|
||||
const ArtifactsComponent = Vue.extend(artifactsComp);
|
||||
|
||||
artifacts = [
|
||||
{
|
||||
name: 'artifact',
|
||||
path: '/download/path',
|
||||
},
|
||||
];
|
||||
|
||||
component = new ArtifactsComponent({
|
||||
propsData: {
|
||||
artifacts,
|
||||
},
|
||||
}).$mount();
|
||||
});
|
||||
|
||||
it('should render a dropdown with the provided artifacts', () => {
|
||||
expect(
|
||||
component.$el.querySelectorAll('.dropdown-menu li').length,
|
||||
).toEqual(artifacts.length);
|
||||
});
|
||||
|
||||
it('should render a link with the provided path', () => {
|
||||
expect(
|
||||
component.$el.querySelector('.dropdown-menu li a').getAttribute('href'),
|
||||
).toEqual(artifacts[0].path);
|
||||
|
||||
expect(
|
||||
component.$el.querySelector('.dropdown-menu li a span').textContent,
|
||||
).toContain(artifacts[0].name);
|
||||
});
|
||||
});
|
72
spec/javascripts/vue_pipelines_index/pipelines_store_spec.js
Normal file
|
@ -0,0 +1,72 @@
|
|||
import PipelineStore from '~/vue_pipelines_index/stores/pipelines_store';
|
||||
|
||||
describe('Pipelines Store', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new PipelineStore();
|
||||
});
|
||||
|
||||
it('should be initialized with an empty state', () => {
|
||||
expect(store.state.pipelines).toEqual([]);
|
||||
expect(store.state.count).toEqual({});
|
||||
expect(store.state.pageInfo).toEqual({});
|
||||
});
|
||||
|
||||
describe('storePipelines', () => {
|
||||
it('should use the default parameter if none is provided', () => {
|
||||
store.storePipelines();
|
||||
expect(store.state.pipelines).toEqual([]);
|
||||
});
|
||||
|
||||
it('should store the provided array', () => {
|
||||
const array = [{ id: 1, status: 'running' }, { id: 2, status: 'success' }];
|
||||
store.storePipelines(array);
|
||||
expect(store.state.pipelines).toEqual(array);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storeCount', () => {
|
||||
it('should use the default parameter if none is provided', () => {
|
||||
store.storeCount();
|
||||
expect(store.state.count).toEqual({});
|
||||
});
|
||||
|
||||
it('should store the provided count', () => {
|
||||
const count = { all: 20, finished: 10 };
|
||||
store.storeCount(count);
|
||||
|
||||
expect(store.state.count).toEqual(count);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storePagination', () => {
|
||||
it('should use the default parameter if none is provided', () => {
|
||||
store.storePagination();
|
||||
expect(store.state.pageInfo).toEqual({});
|
||||
});
|
||||
|
||||
it('should store pagination information normalized and parsed', () => {
|
||||
const pagination = {
|
||||
'X-nExt-pAge': '2',
|
||||
'X-page': '1',
|
||||
'X-Per-Page': '1',
|
||||
'X-Prev-Page': '2',
|
||||
'X-TOTAL': '37',
|
||||
'X-Total-Pages': '2',
|
||||
};
|
||||
|
||||
const expectedResult = {
|
||||
perPage: 1,
|
||||
page: 1,
|
||||
total: 37,
|
||||
totalPages: 2,
|
||||
nextPage: 2,
|
||||
previousPage: 2,
|
||||
};
|
||||
|
||||
store.storePagination(pagination);
|
||||
expect(store.state.pageInfo).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,13 +1,17 @@
|
|||
require('~/vue_shared/components/commit');
|
||||
import Vue from 'vue';
|
||||
import commitComp from '~/vue_shared/components/commit';
|
||||
|
||||
describe('Commit component', () => {
|
||||
let props;
|
||||
let component;
|
||||
let CommitComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
CommitComponent = Vue.extend(commitComp);
|
||||
});
|
||||
|
||||
it('should render a code-fork icon if it does not represent a tag', () => {
|
||||
setFixtures('<div class="test-commit-container"></div>');
|
||||
component = new window.gl.CommitComponent({
|
||||
el: document.querySelector('.test-commit-container'),
|
||||
component = new CommitComponent({
|
||||
propsData: {
|
||||
tag: false,
|
||||
commitRef: {
|
||||
|
@ -23,15 +27,13 @@ describe('Commit component', () => {
|
|||
username: 'jschatz1',
|
||||
},
|
||||
},
|
||||
});
|
||||
}).$mount();
|
||||
|
||||
expect(component.$el.querySelector('.icon-container i').classList).toContain('fa-code-fork');
|
||||
});
|
||||
|
||||
describe('Given all the props', () => {
|
||||
beforeEach(() => {
|
||||
setFixtures('<div class="test-commit-container"></div>');
|
||||
|
||||
props = {
|
||||
tag: true,
|
||||
commitRef: {
|
||||
|
@ -49,10 +51,9 @@ describe('Commit component', () => {
|
|||
commitIconSvg: '<svg></svg>',
|
||||
};
|
||||
|
||||
component = new window.gl.CommitComponent({
|
||||
el: document.querySelector('.test-commit-container'),
|
||||
component = new CommitComponent({
|
||||
propsData: props,
|
||||
});
|
||||
}).$mount();
|
||||
});
|
||||
|
||||
it('should render a tag icon if it represents a tag', () => {
|
||||
|
@ -105,7 +106,6 @@ describe('Commit component', () => {
|
|||
|
||||
describe('When commit title is not provided', () => {
|
||||
it('should render default message', () => {
|
||||
setFixtures('<div class="test-commit-container"></div>');
|
||||
props = {
|
||||
tag: false,
|
||||
commitRef: {
|
||||
|
@ -118,10 +118,9 @@ describe('Commit component', () => {
|
|||
author: {},
|
||||
};
|
||||
|
||||
component = new window.gl.CommitComponent({
|
||||
el: document.querySelector('.test-commit-container'),
|
||||
component = new CommitComponent({
|
||||
propsData: props,
|
||||
});
|
||||
}).$mount();
|
||||
|
||||
expect(
|
||||
component.$el.querySelector('.commit-title span').textContent,
|
||||
|
|
|
@ -1,20 +1,20 @@
|
|||
require('~/vue_shared/components/pipelines_table_row');
|
||||
const pipeline = require('../../commit/pipelines/mock_data');
|
||||
import Vue from 'vue';
|
||||
import tableRowComp from '~/vue_shared/components/pipelines_table_row';
|
||||
import pipeline from '../../commit/pipelines/mock_data';
|
||||
|
||||
describe('Pipelines Table Row', () => {
|
||||
let component;
|
||||
preloadFixtures('static/environments/element.html.raw');
|
||||
|
||||
beforeEach(() => {
|
||||
loadFixtures('static/environments/element.html.raw');
|
||||
const PipelinesTableRowComponent = Vue.extend(tableRowComp);
|
||||
|
||||
component = new gl.pipelines.PipelinesTableRowComponent({
|
||||
component = new PipelinesTableRowComponent({
|
||||
el: document.querySelector('.test-dom-element'),
|
||||
propsData: {
|
||||
pipeline,
|
||||
svgs: {},
|
||||
service: {},
|
||||
},
|
||||
});
|
||||
}).$mount();
|
||||
});
|
||||
|
||||
it('should render a table row', () => {
|
||||
|
|
|
@ -1,24 +1,24 @@
|
|||
require('~/vue_shared/components/pipelines_table');
|
||||
require('~/lib/utils/datetime_utility');
|
||||
const pipeline = require('../../commit/pipelines/mock_data');
|
||||
import Vue from 'vue';
|
||||
import pipelinesTableComp from '~/vue_shared/components/pipelines_table';
|
||||
import '~/lib/utils/datetime_utility';
|
||||
import pipeline from '../../commit/pipelines/mock_data';
|
||||
|
||||
describe('Pipelines Table', () => {
|
||||
preloadFixtures('static/environments/element.html.raw');
|
||||
let PipelinesTableComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
loadFixtures('static/environments/element.html.raw');
|
||||
PipelinesTableComponent = Vue.extend(pipelinesTableComp);
|
||||
});
|
||||
|
||||
describe('table', () => {
|
||||
let component;
|
||||
beforeEach(() => {
|
||||
component = new gl.pipelines.PipelinesTableComponent({
|
||||
el: document.querySelector('.test-dom-element'),
|
||||
component = new PipelinesTableComponent({
|
||||
propsData: {
|
||||
pipelines: [],
|
||||
svgs: {},
|
||||
service: {},
|
||||
},
|
||||
});
|
||||
}).$mount();
|
||||
});
|
||||
|
||||
it('should render a table', () => {
|
||||
|
@ -37,26 +37,25 @@ describe('Pipelines Table', () => {
|
|||
|
||||
describe('without data', () => {
|
||||
it('should render an empty table', () => {
|
||||
const component = new gl.pipelines.PipelinesTableComponent({
|
||||
el: document.querySelector('.test-dom-element'),
|
||||
const component = new PipelinesTableComponent({
|
||||
propsData: {
|
||||
pipelines: [],
|
||||
svgs: {},
|
||||
service: {},
|
||||
},
|
||||
});
|
||||
}).$mount();
|
||||
expect(component.$el.querySelectorAll('tbody tr').length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with data', () => {
|
||||
it('should render rows', () => {
|
||||
const component = new gl.pipelines.PipelinesTableComponent({
|
||||
const component = new PipelinesTableComponent({
|
||||
el: document.querySelector('.test-dom-element'),
|
||||
propsData: {
|
||||
pipelines: [pipeline],
|
||||
svgs: {},
|
||||
service: {},
|
||||
},
|
||||
});
|
||||
}).$mount();
|
||||
|
||||
expect(component.$el.querySelectorAll('tbody tr').length).toEqual(1);
|
||||
});
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
require('~/lib/utils/common_utils');
|
||||
require('~/vue_shared/components/table_pagination');
|
||||
import Vue from 'vue';
|
||||
import paginationComp from '~/vue_shared/components/table_pagination';
|
||||
import '~/lib/utils/common_utils';
|
||||
|
||||
describe('Pagination component', () => {
|
||||
let component;
|
||||
let PaginationComponent;
|
||||
|
||||
const changeChanges = {
|
||||
one: '',
|
||||
|
@ -12,11 +14,12 @@ describe('Pagination component', () => {
|
|||
changeChanges.one = one;
|
||||
};
|
||||
|
||||
it('should render and start at page 1', () => {
|
||||
setFixtures('<div class="test-pagination-container"></div>');
|
||||
beforeEach(() => {
|
||||
PaginationComponent = Vue.extend(paginationComp);
|
||||
});
|
||||
|
||||
component = new window.gl.VueGlPagination({
|
||||
el: document.querySelector('.test-pagination-container'),
|
||||
it('should render and start at page 1', () => {
|
||||
component = new PaginationComponent({
|
||||
propsData: {
|
||||
pageInfo: {
|
||||
totalPages: 10,
|
||||
|
@ -25,7 +28,7 @@ describe('Pagination component', () => {
|
|||
},
|
||||
change,
|
||||
},
|
||||
});
|
||||
}).$mount();
|
||||
|
||||
expect(component.$el.classList).toContain('gl-pagination');
|
||||
|
||||
|
@ -35,10 +38,7 @@ describe('Pagination component', () => {
|
|||
});
|
||||
|
||||
it('should go to the previous page', () => {
|
||||
setFixtures('<div class="test-pagination-container"></div>');
|
||||
|
||||
component = new window.gl.VueGlPagination({
|
||||
el: document.querySelector('.test-pagination-container'),
|
||||
component = new PaginationComponent({
|
||||
propsData: {
|
||||
pageInfo: {
|
||||
totalPages: 10,
|
||||
|
@ -47,7 +47,7 @@ describe('Pagination component', () => {
|
|||
},
|
||||
change,
|
||||
},
|
||||
});
|
||||
}).$mount();
|
||||
|
||||
component.changePage({ target: { innerText: 'Prev' } });
|
||||
|
||||
|
@ -55,10 +55,7 @@ describe('Pagination component', () => {
|
|||
});
|
||||
|
||||
it('should go to the next page', () => {
|
||||
setFixtures('<div class="test-pagination-container"></div>');
|
||||
|
||||
component = new window.gl.VueGlPagination({
|
||||
el: document.querySelector('.test-pagination-container'),
|
||||
component = new PaginationComponent({
|
||||
propsData: {
|
||||
pageInfo: {
|
||||
totalPages: 10,
|
||||
|
@ -67,7 +64,7 @@ describe('Pagination component', () => {
|
|||
},
|
||||
change,
|
||||
},
|
||||
});
|
||||
}).$mount();
|
||||
|
||||
component.changePage({ target: { innerText: 'Next' } });
|
||||
|
||||
|
@ -75,10 +72,7 @@ describe('Pagination component', () => {
|
|||
});
|
||||
|
||||
it('should go to the last page', () => {
|
||||
setFixtures('<div class="test-pagination-container"></div>');
|
||||
|
||||
component = new window.gl.VueGlPagination({
|
||||
el: document.querySelector('.test-pagination-container'),
|
||||
component = new PaginationComponent({
|
||||
propsData: {
|
||||
pageInfo: {
|
||||
totalPages: 10,
|
||||
|
@ -87,7 +81,7 @@ describe('Pagination component', () => {
|
|||
},
|
||||
change,
|
||||
},
|
||||
});
|
||||
}).$mount();
|
||||
|
||||
component.changePage({ target: { innerText: 'Last >>' } });
|
||||
|
||||
|
@ -95,10 +89,7 @@ describe('Pagination component', () => {
|
|||
});
|
||||
|
||||
it('should go to the first page', () => {
|
||||
setFixtures('<div class="test-pagination-container"></div>');
|
||||
|
||||
component = new window.gl.VueGlPagination({
|
||||
el: document.querySelector('.test-pagination-container'),
|
||||
component = new PaginationComponent({
|
||||
propsData: {
|
||||
pageInfo: {
|
||||
totalPages: 10,
|
||||
|
@ -107,7 +98,7 @@ describe('Pagination component', () => {
|
|||
},
|
||||
change,
|
||||
},
|
||||
});
|
||||
}).$mount();
|
||||
|
||||
component.changePage({ target: { innerText: '<< First' } });
|
||||
|
||||
|
@ -115,10 +106,7 @@ describe('Pagination component', () => {
|
|||
});
|
||||
|
||||
it('should do nothing', () => {
|
||||
setFixtures('<div class="test-pagination-container"></div>');
|
||||
|
||||
component = new window.gl.VueGlPagination({
|
||||
el: document.querySelector('.test-pagination-container'),
|
||||
component = new PaginationComponent({
|
||||
propsData: {
|
||||
pageInfo: {
|
||||
totalPages: 10,
|
||||
|
@ -127,7 +115,7 @@ describe('Pagination component', () => {
|
|||
},
|
||||
change,
|
||||
},
|
||||
});
|
||||
}).$mount();
|
||||
|
||||
component.changePage({ target: { innerText: '...' } });
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ describe Gitlab::GitRefValidator, lib: true do
|
|||
it { expect(Gitlab::GitRefValidator.validate('implement_@all')).to be_truthy }
|
||||
it { expect(Gitlab::GitRefValidator.validate('my_new_feature')).to be_truthy }
|
||||
it { expect(Gitlab::GitRefValidator.validate('#1')).to be_truthy }
|
||||
it { expect(Gitlab::GitRefValidator.validate('feature/refs/heads/foo')).to be_truthy }
|
||||
it { expect(Gitlab::GitRefValidator.validate('feature/~new/')).to be_falsey }
|
||||
it { expect(Gitlab::GitRefValidator.validate('feature/^new/')).to be_falsey }
|
||||
it { expect(Gitlab::GitRefValidator.validate('feature/:new/')).to be_falsey }
|
||||
|
@ -17,4 +18,8 @@ describe Gitlab::GitRefValidator, lib: true do
|
|||
it { expect(Gitlab::GitRefValidator.validate('feature\new')).to be_falsey }
|
||||
it { expect(Gitlab::GitRefValidator.validate('feature//new')).to be_falsey }
|
||||
it { expect(Gitlab::GitRefValidator.validate('feature new')).to be_falsey }
|
||||
it { expect(Gitlab::GitRefValidator.validate('refs/heads/')).to be_falsey }
|
||||
it { expect(Gitlab::GitRefValidator.validate('refs/remotes/')).to be_falsey }
|
||||
it { expect(Gitlab::GitRefValidator.validate('refs/heads/feature')).to be_falsey }
|
||||
it { expect(Gitlab::GitRefValidator.validate('refs/remotes/origin')).to be_falsey }
|
||||
end
|
||||
|
|
|
@ -1,10 +1,16 @@
|
|||
require 'spec_helper'
|
||||
|
||||
class MigrationTest
|
||||
include Gitlab::Database
|
||||
end
|
||||
|
||||
describe Gitlab::Database, lib: true do
|
||||
before do
|
||||
stub_const('MigrationTest', Class.new { include Gitlab::Database })
|
||||
end
|
||||
|
||||
describe '.config' do
|
||||
it 'returns a Hash' do
|
||||
expect(described_class.config).to be_an_instance_of(Hash)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.adapter_name' do
|
||||
it 'returns the name of the adapter' do
|
||||
expect(described_class.adapter_name).to be_an_instance_of(String)
|
||||
|
|
|
@ -109,6 +109,43 @@ EOT
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'using a Gitaly::CommitDiffResponse' do
|
||||
let(:diff) do
|
||||
described_class.new(
|
||||
Gitaly::CommitDiffResponse.new(
|
||||
to_path: ".gitmodules",
|
||||
from_path: ".gitmodules",
|
||||
old_mode: 0100644,
|
||||
new_mode: 0100644,
|
||||
from_id: '357406f3075a57708d0163752905cc1576fceacc',
|
||||
to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0',
|
||||
raw_chunks: raw_chunks,
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
context 'with a small diff' do
|
||||
let(:raw_chunks) { [@raw_diff_hash[:diff]] }
|
||||
|
||||
it 'initializes the diff' do
|
||||
expect(diff.to_hash).to eq(@raw_diff_hash)
|
||||
end
|
||||
|
||||
it 'does not prune the diff' do
|
||||
expect(diff).not_to be_too_large
|
||||
end
|
||||
end
|
||||
|
||||
context 'using a diff that is too large' do
|
||||
let(:raw_chunks) { ['a' * 204800] }
|
||||
|
||||
it 'prunes the diff' do
|
||||
expect(diff.diff).to be_empty
|
||||
expect(diff).to be_too_large
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'straight diffs' do
|
||||
|
|