Commit 059ad475 authored by Filipa Lacerda's avatar Filipa Lacerda

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
  ...
parents b5c80f99 d90c2505
......@@ -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.)
......@@ -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,
......
/* 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),
},
};
/* 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);
......
/* 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;
/* 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();
eventHub.$on('refreshPipelines', this.fetchPipelines);
},
beforeUpdate() {
if (this.state.pipelines.length && this.$children) {
this.store.startTimeAgoLoops.call(this, Vue);
}
},
/**
* 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);
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>
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="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>
<div class="table-holder pipelines"
v-if="!isLoading && state.pipelines.length > 0">
<pipelines-table-component
:pipelines="state.pipelines"
:service="service" />
</div>
`,
});
})();
</div>
`,
});
/* 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);
},
},
/**
......
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,
......
/**
* Render environments table.
*/
import EnvironmentItem from './environment_item';
import EnvironmentTableRowComponent from './environment_item';
export default {
components: {
'environment-item': EnvironmentItem,
'environment-item': EnvironmentTableRowComponent,
},
props: {
......
/* 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() {
......
/* eslint-disable class-methods-use-this */
import Vue from 'vue';
import VueResource from 'vue-resource';
Vue.use(VueResource);
export default class EnvironmentsService {
constructor(endpoint) {
......
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;
......
/* 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>
`,
};
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>
`,
};
/* 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>
`,
};
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>
`,
};
/* 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>
`,
};
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>
`,
};
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>
`,
};
import Vue from 'vue';
export default new Vue();
/* 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" />
`,
}));
/* 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 = {}));
/* 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 = {}));
/* 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 });
}
}
/* 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 = {}));
/* 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 = {}));
/* 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 = {}));
/* 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');
class PipelinesStore {
import '../../vue_realtime_listener';
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;
/* 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 = {}));
/* global Vue */
window.Vue = require('vue');
const commitIconSvg = require('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: () => ({}),
},
import commitIconSvg from 'icons/_icon_commit.svg';
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">
<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>
/**
* 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: () => ({}),
},
},
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;
},
},
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>
`,
};
/* 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,
},
},
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"></tr>
</template>
</tbody>
</table>
`,
});
})();
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>
`,
};
/* global Vue, gl */
/* eslint-disable no-param-reassign, no-plusplus */
window.Vue = require('vue');
((gl) => {
const PAGINATION_UI_BUTTON_LIMIT = 4;
const UI_LIMIT = 6;
const SPREAD = '...';
const PREV = 'Prev';
const NEXT = 'Next';
const FIRST = '<< First';
const LAST = 'Last >>';
gl.VueGlPagination = Vue.extend({
props: {
// 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,
const PAGINATION_UI_BUTTON_LIMIT = 4;
const UI_LIMIT = 6;
const SPREAD = '...';
const PREV = 'Prev';
const NEXT = 'Next';
const FIRST = '<< First';
const LAST = 'Last >>';
export default {
props: {
/**
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,
},
/**
pageInfo will come from the headers of the API call
in the `.then` clause of the VueResource API call
there should be a function that contructs the pageInfo for this component
This is an example:
const pageInfo = headers => ({
perPage: +headers['X-Per-Page'],
page: +headers['X-Page'],
total: +headers['X-Total'],
totalPages: +headers['X-Total-Pages'],
nextPage: +headers['X-Next-Page'],
previousPage: +headers['X-Prev-Page'],
});
*/
pageInfo: {
type: Object,
required: true,
},
methods: {
changePage(e) {
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;
}
},
},
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;
}
},
computed: {
prev() {
return this.pageInfo.previousPage;
},
next() {
return this.pageInfo.nextPage;
},
getItems() {
const total = this.pageInfo.totalPages;
const page = this.pageInfo.page;
const items = [];
},
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: FIRST });
if (page > 1) {
items.push({ title: PREV, prev: true });
} else {
items.push({ title: PREV, disabled: true, prev: true });
}
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 });
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);
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 });
}
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 (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 (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 });
if (total - page >= 1) items.push({ title: LAST, last: true });
return items;
},
return items;
},
template: `
<div class="gl-pagination">
<ul class="pagination clearfix">
<li v-for='item in getItems'
:class='{
page: item.page,
prev: item.prev,
next: item.next,
separator: item.separator,
active: item.active,
disabled: item.disabled
}'
>
<a @click="changePage($event)">{{item.title}}</a>
</li>
</ul>
</div>
`,
});
})(window.gl || (window.gl = {}));
},
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>
`,
};
/* 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|
......
#
# 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
This diff is collapsed.
......@@ -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
%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
......
---
title: Add ability to disable Merge Request URL on push
merge_request: 9663
author: Alex Sanford
---
title: Strip reference prefixes on branch creation
merge_request: 8498
author: Matthieu Tardy
---
title: Use "branch_name" instead "branch" on V3 branch creation API
merge_request:
author:
---
title: Added labels array to the issue web hook returned object
merge_request: 9972
author:
---
title: Use Gitaly for CommitController#show
merge_request: 9629
author:
---
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)
......
# 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
......
# 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
......@@ -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
......
# 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).
......@@ -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
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")
......
/* 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();
});
});
});
/* 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;
/* 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'),
});
......
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);
});
});
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');
});
});
});
This diff is collapsed.
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);
});
});
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment