Commit 8800c0da authored by Rémy Coutable's avatar Rémy Coutable

Merge branch 'rc/ce-to-ee-friday' into 'master'

CE Upstream - Friday

Closes #1361, #1652, gitlab-ce#20305, and gitlab-ce#24036

See merge request !1257
parents c7616d89 f107247a
### Background:
(Include problem, use cases, benefits, and/or goals)
**What questions are you trying to answer?**
**Are you looking to verify an existing hypothesis or uncover new issues you should be exploring?**
**What is the backstory of this project and how does it impact the approach?**
**What do you already know about the areas you are exploring?**
**What does success look like at the end of the project?**
### Links / references:
/label ~"UX research"
...@@ -93,18 +93,20 @@ Please see the [UX Guide for GitLab]. ...@@ -93,18 +93,20 @@ Please see the [UX Guide for GitLab].
### Retrospective ### Retrospective
After each release (usually on the 22nd of each month), we have a retrospective After each release, we have a retrospective call where we discuss what went well,
call where we discuss what went well, what went wrong, and what we can improve what went wrong, and what we can improve for the next release. The
for the next release. The [retrospective notes] are public and you are invited [retrospective notes] are public and you are invited to comment on them.
to comment them. If you're interested, you can even join the
If you're interested, you can even join the [retrospective call][retro-kickoff-call]. [retrospective call][retro-kickoff-call], on the first working day after the
22nd at 6pm CET / 9am PST.
### Kickoff ### Kickoff
Before working on the next release (usually on the 8th of each month), we have a Before working on the next release, we have a
kickoff call to explain what we expect to ship in the next release. The kickoff call to explain what we expect to ship in the next release. The
[kickoff notes] are public and you are invited to comment them. [kickoff notes] are public and you are invited to comment on them.
If you're interested, you can even join the [kickoff call][retro-kickoff-call]. If you're interested, you can even join the [kickoff call][retro-kickoff-call],
on the first working day after the 7th at 6pm CET / 9am PST..
[retrospective notes]: https://docs.google.com/document/d/1nEkM_7Dj4bT21GJy0Ut3By76FZqCfLBmFQNVThmW2TY/edit?usp=sharing [retrospective notes]: https://docs.google.com/document/d/1nEkM_7Dj4bT21GJy0Ut3By76FZqCfLBmFQNVThmW2TY/edit?usp=sharing
[kickoff notes]: https://docs.google.com/document/d/1ElPkZ90A8ey_iOkTvUs_ByMlwKK6NAB2VOK5835wYK0/edit?usp=sharing [kickoff notes]: https://docs.google.com/document/d/1ElPkZ90A8ey_iOkTvUs_ByMlwKK6NAB2VOK5835wYK0/edit?usp=sharing
......
...@@ -103,11 +103,6 @@ require('es6-promise').polyfill(); ...@@ -103,11 +103,6 @@ require('es6-promise').polyfill();
} }
}); });
$('.nav-sidebar').niceScroll({
cursoropacitymax: '0.4',
cursorcolor: '#FFF',
cursorborder: '1px solid #FFF'
});
$('.js-select-on-focus').on('focusin', function () { $('.js-select-on-focus').on('focusin', function () {
return $(this).select().one('mouseup', function (e) { return $(this).select().one('mouseup', function (e) {
return e.preventDefault(); return e.preventDefault();
...@@ -250,8 +245,6 @@ require('es6-promise').polyfill(); ...@@ -250,8 +245,6 @@ require('es6-promise').polyfill();
}); });
gl.awardsHandler = new AwardsHandler(); gl.awardsHandler = new AwardsHandler();
new Aside(); new Aside();
// bind sidebar events
new gl.Sidebar();
gl.utils.initTimeagoTimeout(); gl.utils.initTimeagoTimeout();
}); });
......
...@@ -97,7 +97,7 @@ $(() => { ...@@ -97,7 +97,7 @@ $(() => {
}, },
computed: { computed: {
disabled() { disabled() {
return Store.shouldAddBlankState(); return !this.store.lists.filter(list => list.type !== 'blank' && list.type !== 'done').length;
}, },
}, },
template: ` template: `
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
* *
* Used to store the Pipelines rendered in the commit view in the pipelines table. * Used to store the Pipelines rendered in the commit view in the pipelines table.
*/ */
require('../../vue_realtime_listener');
class PipelinesStore { class PipelinesStore {
constructor() { constructor() {
...@@ -24,7 +25,7 @@ class PipelinesStore { ...@@ -24,7 +25,7 @@ class PipelinesStore {
* update the time to show how long as passed. * update the time to show how long as passed.
* *
*/ */
startTimeAgoLoops() { static startTimeAgoLoops() {
const startTimeLoops = () => { const startTimeLoops = () => {
this.timeLoopInterval = setInterval(() => { this.timeLoopInterval = setInterval(() => {
this.$children[0].$children.reduce((acc, component) => { this.$children[0].$children.reduce((acc, component) => {
...@@ -44,7 +45,4 @@ class PipelinesStore { ...@@ -44,7 +45,4 @@ class PipelinesStore {
} }
} }
window.gl = window.gl || {}; module.exports = PipelinesStore;
gl.commits = gl.commits || {};
gl.commits.pipelines = gl.commits.pipelines || {};
gl.commits.pipelines.PipelinesStore = PipelinesStore;
...@@ -6,9 +6,8 @@ window.Vue.use(require('vue-resource')); ...@@ -6,9 +6,8 @@ window.Vue.use(require('vue-resource'));
require('../../lib/utils/common_utils'); require('../../lib/utils/common_utils');
require('../../vue_shared/vue_resource_interceptor'); require('../../vue_shared/vue_resource_interceptor');
require('../../vue_shared/components/pipelines_table'); require('../../vue_shared/components/pipelines_table');
require('../../vue_realtime_listener/index');
require('./pipelines_service'); require('./pipelines_service');
require('./pipelines_store'); const PipelineStore = require('./pipelines_store');
/** /**
* *
...@@ -41,7 +40,7 @@ require('./pipelines_store'); ...@@ -41,7 +40,7 @@ require('./pipelines_store');
data() { data() {
const pipelinesTableData = document.querySelector('#commit-pipeline-table-view').dataset; const pipelinesTableData = document.querySelector('#commit-pipeline-table-view').dataset;
const svgsData = document.querySelector('.pipeline-svgs').dataset; const svgsData = document.querySelector('.pipeline-svgs').dataset;
const store = new gl.commits.pipelines.PipelinesStore(); const store = new PipelineStore();
// Transform svgs DOMStringMap to a plain Object. // Transform svgs DOMStringMap to a plain Object.
const svgsObject = gl.utils.DOMStringMapToObject(svgsData); const svgsObject = gl.utils.DOMStringMapToObject(svgsData);
...@@ -71,7 +70,6 @@ require('./pipelines_store'); ...@@ -71,7 +70,6 @@ require('./pipelines_store');
.then(response => response.json()) .then(response => response.json())
.then((json) => { .then((json) => {
this.store.storePipelines(json); this.store.storePipelines(json);
this.store.startTimeAgoLoops.call(this, Vue);
this.isLoading = false; this.isLoading = false;
}) })
.catch(() => { .catch(() => {
...@@ -80,9 +78,15 @@ require('./pipelines_store'); ...@@ -80,9 +78,15 @@ require('./pipelines_store');
}); });
}, },
beforeUpdate() {
if (this.state.pipelines.length && this.$children) {
PipelineStore.startTimeAgoLoops.call(this, Vue);
}
},
template: ` template: `
<div> <div class="pipelines">
<div class="pipelines realtime-loading" v-if="isLoading"> <div class="realtime-loading" v-if="isLoading">
<i class="fa fa-spinner fa-spin"></i> <i class="fa fa-spinner fa-spin"></i>
</div> </div>
......
...@@ -121,6 +121,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); ...@@ -121,6 +121,7 @@ const ShortcutsBlob = require('./shortcuts_blob');
new gl.IssuableTemplateSelectors(); new gl.IssuableTemplateSelectors();
break; break;
case 'projects:merge_requests:new': case 'projects:merge_requests:new':
case 'projects:merge_requests:new_diffs':
case 'projects:merge_requests:edit': case 'projects:merge_requests:edit':
new gl.Diff(); new gl.Diff();
shortcut_handler = new ShortcutsNavigation(); shortcut_handler = new ShortcutsNavigation();
......
...@@ -48,7 +48,7 @@ ...@@ -48,7 +48,7 @@
const calendar = new Pikaday({ const calendar = new Pikaday({
field: $dueDateInput.get(0), field: $dueDateInput.get(0),
theme: 'gitlab-theme', theme: 'gitlab-theme',
format: 'YYYY-MM-DD', format: 'yyyy-mm-dd',
onSelect: (dateText) => { onSelect: (dateText) => {
const formattedDate = dateFormat(new Date(dateText), 'yyyy-mm-dd'); const formattedDate = dateFormat(new Date(dateText), 'yyyy-mm-dd');
...@@ -63,6 +63,7 @@ ...@@ -63,6 +63,7 @@
} }
}); });
calendar.setDate(new Date($dueDateInput.val()));
this.$datePicker.append(calendar.el); this.$datePicker.append(calendar.el);
this.$datePicker.data('pikaday', calendar); this.$datePicker.data('pikaday', calendar);
} }
...@@ -169,11 +170,12 @@ ...@@ -169,11 +170,12 @@
const calendar = new Pikaday({ const calendar = new Pikaday({
field: $datePicker.get(0), field: $datePicker.get(0),
theme: 'gitlab-theme', theme: 'gitlab-theme',
format: 'YYYY-MM-DD', format: 'yyyy-mm-dd',
onSelect(dateText) { onSelect(dateText) {
$datePicker.val(dateFormat(new Date(dateText), 'yyyy-mm-dd')); $datePicker.val(dateFormat(new Date(dateText), 'yyyy-mm-dd'));
} }
}); });
calendar.setDate(new Date($datePicker.val()));
$datePicker.data('pikaday', calendar); $datePicker.data('pikaday', calendar);
}); });
......
/* global Vue */ const Vue = require('vue');
window.Vue = require('vue'); module.exports = Vue.component('actions-component', {
props: {
(() => { actions: {
window.gl = window.gl || {}; type: Array,
window.gl.environmentsList = window.gl.environmentsList || {}; required: false,
default: () => [],
gl.environmentsList.ActionsComponent = Vue.component('actions-component', {
props: {
actions: {
type: Array,
required: false,
default: () => [],
},
playIconSvg: {
type: String,
required: false,
},
}, },
template: ` playIconSvg: {
<div class="inline"> type: String,
<div class="dropdown"> required: false,
<a class="dropdown-new btn btn-default" data-toggle="dropdown"> },
<span class="js-dropdown-play-icon-container" v-html="playIconSvg"></span> },
<i class="fa fa-caret-down"></i>
</a> template: `
<div class="inline">
<ul class="dropdown-menu dropdown-menu-align-right"> <div class="dropdown">
<li v-for="action in actions"> <a class="dropdown-new btn btn-default" data-toggle="dropdown">
<a :href="action.play_path" <span class="js-dropdown-play-icon-container" v-html="playIconSvg"></span>
data-method="post" <i class="fa fa-caret-down"></i>
rel="nofollow" </a>
class="js-manual-action-link">
<ul class="dropdown-menu dropdown-menu-align-right">
<span class="js-action-play-icon-container" v-html="playIconSvg"></span> <li v-for="action in actions">
<a :href="action.play_path"
<span> data-method="post"
{{action.name}} rel="nofollow"
</span> class="js-manual-action-link">
</a>
</li> <span class="js-action-play-icon-container" v-html="playIconSvg"></span>
</ul>
</div> <span>
{{action.name}}
</span>
</a>
</li>
</ul>
</div> </div>
`, </div>
}); `,
})(); });
/* global Vue */ /**
* Renders the external url link in environments table.
*/
const Vue = require('vue');
window.Vue = require('vue'); module.exports = Vue.component('external-url-component', {
props: {
(() => { externalUrl: {
window.gl = window.gl || {}; type: String,
window.gl.environmentsList = window.gl.environmentsList || {}; default: '',
gl.environmentsList.ExternalUrlComponent = Vue.component('external-url-component', {
props: {
externalUrl: {
type: String,
default: '',
},
}, },
},
template: ` template: `
<a class="btn external_url" :href="externalUrl" target="_blank"> <a class="btn external_url" :href="externalUrl" target="_blank">
<i class="fa fa-external-link"></i> <i class="fa fa-external-link"></i>
</a> </a>
`, `,
}); });
})();
/* global Vue */ /**
* Renders Rollback or Re deploy button in environments table depending
* of the provided property `isLastDeployment`
*/
const Vue = require('vue');
window.Vue = require('vue'); module.exports = Vue.component('rollback-component', {
props: {
(() => { retryUrl: {
window.gl = window.gl || {}; type: String,
window.gl.environmentsList = window.gl.environmentsList || {}; default: '',
},
gl.environmentsList.RollbackComponent = Vue.component('rollback-component', {
props: {
retryUrl: {
type: String,
default: '',
},
isLastDeployment: { isLastDeployment: {
type: Boolean, type: Boolean,
default: true, default: true,
},
}, },
},
template: ` template: `
<a class="btn" :href="retryUrl" data-method="post" rel="nofollow"> <a class="btn" :href="retryUrl" data-method="post" rel="nofollow">
<span v-if="isLastDeployment"> <span v-if="isLastDeployment">
Re-deploy Re-deploy
</span> </span>
<span v-else> <span v-else>
Rollback Rollback
</span> </span>
</a> </a>
`, `,
}); });
})();
/* global Vue */ /**
* Renders the stop "button" that allows stop an environment.
* Used in environments table.
*/
const Vue = require('vue');
window.Vue = require('vue'); module.exports = Vue.component('stop-component', {
props: {
(() => { stopUrl: {
window.gl = window.gl || {}; type: String,
window.gl.environmentsList = window.gl.environmentsList || {}; default: '',
gl.environmentsList.StopComponent = Vue.component('stop-component', {
props: {
stopUrl: {
type: String,
default: '',
},
}, },
},
template: ` template: `
<a class="btn stop-env-link" <a class="btn stop-env-link"
:href="stopUrl" :href="stopUrl"
data-confirm="Are you sure you want to stop this environment?" data-confirm="Are you sure you want to stop this environment?"
data-method="post" data-method="post"
rel="nofollow"> rel="nofollow">
<i class="fa fa-stop stop-env-icon"></i> <i class="fa fa-stop stop-env-icon" aria-hidden="true"></i>
</a> </a>
`, `,
}); });
})();
/* global Vue */ /**
* Renders a terminal button to open a web terminal.
* Used in environments table.
*/
const Vue = require('vue');
window.Vue = require('vue'); module.exports = Vue.component('terminal-button-component', {
props: {
(() => { terminalPath: {
window.gl = window.gl || {}; type: String,
window.gl.environmentsList = window.gl.environmentsList || {}; default: '',
},
gl.environmentsList.TerminalButtonComponent = Vue.component('terminal-button-component', { terminalIconSvg: {
props: { type: String,
terminalPath: { default: '',
type: String,
default: '',
},
terminalIconSvg: {
type: String,
default: '',
},
}, },
},
template: ` template: `
<a class="btn terminal-button" <a class="btn terminal-button"
:href="terminalPath"> :href="terminalPath">
<span class="js-terminal-icon-container" v-html="terminalIconSvg"></span> <span class="js-terminal-icon-container" v-html="terminalIconSvg"></span>
</a> </a>
`, `,
}); });
})();
/**
* Render environments table.
*/
const Vue = require('vue');
const EnvironmentItem = require('./environment_item');
module.exports = Vue.component('environment-table-component', {
components: {
'environment-item': EnvironmentItem,
},
props: {
environments: {
type: Array,
required: true,
default: () => ([]),
},
canReadEnvironment: {
type: Boolean,
required: false,
default: false,
},
canCreateDeployment: {
type: Boolean,
required: false,
default: false,
},
commitIconSvg: {
type: String,
required: false,
},
playIconSvg: {
type: String,
required: false,
},
terminalIconSvg: {
type: String,
required: false,
},
},
template: `
<table class="table ci-table environments">
<thead>
<tr>
<th class="environments-name">Environment</th>
<th class="environments-deploy">Last deployment</th>
<th class="environments-build">Job</th>
<th class="environments-commit">Commit</th>
<th class="environments-date">Updated</th>
<th class="hidden-xs environments-actions"></th>
</tr>
</thead>
<tbody>
<template v-for="model in environments"
v-bind:model="model">
<tr is="environment-item"
:model="model"
:can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment"
:play-icon-svg="playIconSvg"
:terminal-icon-svg="terminalIconSvg"
:commit-icon-svg="commitIconSvg"></tr>
</template>
</tbody>
</table>
`,
});
window.Vue = require('vue'); const EnvironmentsComponent = require('./components/environment');
require('./stores/environments_store');
require('./components/environment');
require('../vue_shared/vue_resource_interceptor');
$(() => { $(() => {
window.gl = window.gl || {}; window.gl = window.gl || {};
...@@ -9,14 +6,8 @@ $(() => { ...@@ -9,14 +6,8 @@ $(() => {
if (gl.EnvironmentsListApp) { if (gl.EnvironmentsListApp) {
gl.EnvironmentsListApp.$destroy(true); gl.EnvironmentsListApp.$destroy(true);
} }
const Store = gl.environmentsList.EnvironmentsStore;
gl.EnvironmentsListApp = new gl.environmentsList.EnvironmentsComponent({ gl.EnvironmentsListApp = new EnvironmentsComponent({
el: document.querySelector('#environments-list-view'), el: document.querySelector('#environments-list-view'),
propsData: {
store: Store.create(),
},
}); });
}); });
const EnvironmentsFolderComponent = require('./environments_folder_view');
$(() => {
window.gl = window.gl || {};
if (gl.EnvironmentsListFolderApp) {
gl.EnvironmentsListFolderApp.$destroy(true);
}
gl.EnvironmentsListFolderApp = new EnvironmentsFolderComponent({
el: document.querySelector('#environments-folder-list-view'),
});
});
/* eslint-disable no-param-reassign, no-new */
/* global Flash */
const Vue = window.Vue = require('vue');
window.Vue.use(require('vue-resource'));
const EnvironmentsService = require('../services/environments_service');
const EnvironmentTable = require('../components/environments_table');
const EnvironmentsStore = require('../stores/environments_store');
require('../../vue_shared/components/table_pagination');
require('../../lib/utils/common_utils');
require('../../vue_shared/vue_resource_interceptor');
module.exports = Vue.component('environment-folder-view', {
components: {
'environment-table': EnvironmentTable,
'table-pagination': gl.VueGlPagination,
},
data() {
const environmentsData = document.querySelector('#environments-folder-list-view').dataset;
const store = new EnvironmentsStore();
const pathname = window.location.pathname;
const endpoint = `${pathname}.json`;
const folderName = pathname.substr(pathname.lastIndexOf('/') + 1);
return {
store,
folderName,
endpoint,
state: store.state,
visibility: 'available',
isLoading: false,
cssContainerClass: environmentsData.cssClass,
canCreateDeployment: environmentsData.canCreateDeployment,
canReadEnvironment: environmentsData.canReadEnvironment,
// svgs
commitIconSvg: environmentsData.commitIconSvg,
playIconSvg: environmentsData.playIconSvg,
terminalIconSvg: environmentsData.terminalIconSvg,
// Pagination Properties,
paginationInformation: {},
pageNumber: 1,
};
},
computed: {
scope() {
return gl.utils.getParameterByName('scope');
},
canReadEnvironmentParsed() {
return gl.utils.convertPermissionToBoolean(this.canReadEnvironment);
},
canCreateDeploymentParsed() {
return gl.utils.convertPermissionToBoolean(this.canCreateDeployment);
},
/**
* URL to link in the stopped tab.
*
* @return {String}
*/
stoppedPath() {
return `${window.location.pathname}?scope=stopped`;
},
/**
* URL to link in the available tab.
*
* @return {String}
*/
availablePath() {
return window.location.pathname;
},
},
/**
* Fetches all the environments and stores them.
* Toggles loading property.
*/
created() {
const scope = gl.utils.getParameterByName('scope') || this.visibility;
const pageNumber = gl.utils.getParameterByName('page') || this.pageNumber;
const endpoint = `${this.endpoint}?scope=${scope}&page=${pageNumber}`;
const service = new EnvironmentsService(endpoint);
this.isLoading = true;
return service.all()
.then(resp => ({
headers: resp.headers,
body: resp.json(),
}))
.then((response) => {
this.store.storeAvailableCount(response.body.available_count);
this.store.storeStoppedCount(response.body.stopped_count);
this.store.storeEnvironments(response.body.environments);
this.store.setPagination(response.headers);
})
.then(() => {
this.isLoading = false;
})
.catch(() => {
this.isLoading = false;
new Flash('An error occurred while fetching the environments.', 'alert');
});
},
methods: {
/**
* Will change the page number and update the URL.
*
* @param {Number} pageNumber desired page to go to.
*/
changePage(pageNumber) {
const param = gl.utils.setParamInURL('page', pageNumber);
gl.utils.visitUrl(param);
return param;
},
},
template: `
<div :class="cssContainerClass">
<div class="top-area" v-if="!isLoading">
<h4 class="js-folder-name environments-folder-name">
Environments / <b>{{folderName}}</b>
</h4>
<ul class="nav-links">
<li v-bind:class="{ 'active': scope === null || scope === 'available' }">
<a :href="availablePath" class="js-available-environments-folder-tab">
Available
<span class="badge js-available-environments-count">
{{state.availableCounter}}
</span>
</a>
</li>
<li v-bind:class="{ 'active' : scope === 'stopped' }">
<a :href="stoppedPath" class="js-stopped-environments-folder-tab">
Stopped
<span class="badge js-stopped-environments-count">
{{state.stoppedCounter}}
</span>
</a>
</li>
</ul>
</div>
<div class="environments-container">
<div class="environments-list-loading text-center" v-if="isLoading">
<i class="fa fa-spinner fa-spin"></i>
</div>
<div class="table-holder"
v-if="!isLoading && state.environments.length > 0">
<environment-table
:environments="state.environments"
:can-create-deployment="canCreateDeploymentParsed"
:can-read-environment="canReadEnvironmentParsed"
:play-icon-svg="playIconSvg"
:terminal-icon-svg="terminalIconSvg"
:commit-icon-svg="commitIconSvg">
</environment-table>
<table-pagination v-if="state.paginationInformation && state.paginationInformation.totalPages > 1"
:change="changePage"
:pageInfo="state.paginationInformation">
</table-pagination>
</div>
</div>
</div>
`,
});
/* globals Vue */ const Vue = require('vue');
/* eslint-disable no-unused-vars, no-param-reassign */
class EnvironmentsService { class EnvironmentsService {
constructor(endpoint) {
constructor(root) { this.environments = Vue.resource(endpoint);
Vue.http.options.root = root;
this.environments = Vue.resource(root);
Vue.http.interceptors.push((request, next) => {
// needed in order to not break the tests.
if ($.rails) {
request.headers['X-CSRF-Token'] = $.rails.csrfToken();
}
next();
});
} }
all() { all() {
...@@ -22,4 +10,4 @@ class EnvironmentsService { ...@@ -22,4 +10,4 @@ class EnvironmentsService {
} }
} }
window.EnvironmentsService = EnvironmentsService; module.exports = EnvironmentsService;
/* eslint-disable no-param-reassign */ require('~/lib/utils/common_utils');
(() => { /**
window.gl = window.gl || {}; * Environments Store.
window.gl.environmentsList = window.gl.environmentsList || {}; *
* Stores received environments, count of stopped environments and count of
gl.environmentsList.EnvironmentsStore = { * available environments.
state: {}, */
class EnvironmentsStore {
create() { constructor() {
this.state.environments = []; this.state = {};
this.state.stoppedCounter = 0; this.state.environments = [];
this.state.availableCounter = 0; this.state.stoppedCounter = 0;
this.state.visibility = 'available'; this.state.availableCounter = 0;
this.state.filteredEnvironments = []; this.state.paginationInformation = {};
return this; return this;
}, }
/** /**
* In order to display a tree view we need to modify the received *
* data in to a tree structure based on `environment_type` * Stores the received environments.
* sorted alphabetically. *
* In each children a `vue-` property will be added. This property will be * In the main environments endpoint, each environment has the following schema
* used to know if an item is a children mostly for css purposes. This is * { name: String, size: Number, latest: Object }
* needed because the children row is a fragment instance and therfore does * In the endpoint to retrieve environments from each folder, the environment does
* not accept non-prop attributes. * not have the `latest` key and the data is all in the root level.
* * To avoid doing this check in the view, we store both cases the same by extracting
* * what is inside the `latest` key.
* @example *
* it will transform this: * If the `size` is bigger than 1, it means it should be rendered as a folder.
* [ * In those cases we add `isFolder` key in order to render it properly.
* { name: "environment", environment_type: "review" }, *
* { name: "environment_1", environment_type: null } * @param {Array} environments
* { name: "environment_2, environment_type: "review" } * @returns {Array}
* ] */
* into this: storeEnvironments(environments = []) {
* [ const filteredEnvironments = environments.map((env) => {
* { name: "review", children: let filtered = {};
* [
* { name: "environment", environment_type: "review", vue-isChildren: true}, if (env.size > 1) {
* { name: "environment_2", environment_type: "review", vue-isChildren: true} filtered = Object.assign({}, env, { isFolder: true, folderName: env.name });
* ] }
* },
* {name: "environment_1", environment_type: null} if (env.latest) {
* ] filtered = Object.assign(filtered, env, env.latest);
* delete filtered.latest;
* } else {
* @param {Array} environments List of environments. filtered = Object.assign(filtered, env);
* @returns {Array} Tree structured array with the received environments. }
*/
storeEnvironments(environments = []) { return filtered;
this.state.stoppedCounter = this.countByState(environments, 'stopped'); });
this.state.availableCounter = this.countByState(environments, 'available');
this.state.environments = filteredEnvironments;
const environmentsTree = environments.reduce((acc, environment) => {
if (environment.environment_type !== null) { return filteredEnvironments;
const occurs = acc.filter(element => element.children && }
element.name === environment.environment_type);
setPagination(pagination = {}) {
environment['vue-isChildren'] = true; const normalizedHeaders = gl.utils.normalizeHeaders(pagination);
const paginationInformation = gl.utils.parseIntPagination(normalizedHeaders);
if (occurs.length) {
acc[acc.indexOf(occurs[0])].children.push(environment); this.state.paginationInformation = paginationInformation;
acc[acc.indexOf(occurs[0])].children.slice().sort(this.sortByName); return paginationInformation;
} else { }
acc.push({
name: environment.environment_type, /**
children: [environment], * Stores the number of available environments.
isOpen: false, *
'vue-isChildren': environment['vue-isChildren'], * @param {Number} count = 0
}); * @return {Number}
} */
} else { storeAvailableCount(count = 0) {
acc.push(environment); this.state.availableCounter = count;
} return count;
}
return acc;
}, []).slice().sort(this.sortByName); /**
* Stores the number of closed environments.
this.state.environments = environmentsTree; *
* @param {Number} count = 0
this.filterEnvironmentsByVisibility(this.state.environments); * @return {Number}
*/
return environmentsTree; storeStoppedCount(count = 0) {
}, this.state.stoppedCounter = count;
return count;
storeVisibility(visibility) { }
this.state.visibility = visibility; }
},
/** module.exports = EnvironmentsStore;
* Given the visibility prop provided by the url query parameter and which
* changes according to the active tab we need to filter which environments
* should be visible.
*
* The environments array is a recursive tree structure and we need to filter
* both root level environments and children environments.
*
* In order to acomplish that, both `filterState` and `filterEnvironmentsByVisibility`
* functions work together.
* The first one works as the filter that verifies if the given environment matches
* the given state.
* The second guarantees both root level and children elements are filtered as well.
*
* Given array of environments will return only
* the environments that match the state stored.
*
* @param {Array} array
* @return {Array}
*/
filterEnvironmentsByVisibility(arr) {
const filteredEnvironments = arr.map((item) => {
if (item.children) {
const filteredChildren = this.filterEnvironmentsByVisibility(
item.children,
).filter(Boolean);
if (filteredChildren.length) {
item.children = filteredChildren;
return item;
}
}
return this.filterState(this.state.visibility, item);
}).filter(Boolean);
this.state.filteredEnvironments = filteredEnvironments;
return filteredEnvironments;
},
/**
* Given the state and the environment,
* returns only if the environment state matches the one provided.
*
* @param {String} state
* @param {Object} environment
* @return {Object}
*/
filterState(state, environment) {
return environment.state === state && environment;
},
/**
* Toggles folder open property given the environment type.
*
* @param {String} envType
* @return {Array}
*/
toggleFolder(envType) {
const environments = this.state.environments;
const environmentsCopy = environments.map((env) => {
if (env['vue-isChildren'] && env.name === envType) {
env.isOpen = !env.isOpen;
}
return env;
});
this.state.environments = environmentsCopy;
return environmentsCopy;
},
/**
* Given an array of environments, returns the number of environments
* that have the given state.
*
* @param {Array} environments
* @param {String} state
* @returns {Number}
*/
countByState(environments, state) {
return environments.filter(env => env.state === state).length;
},
/**
* Sorts the two objects provided by their name.
*
* @param {Object} a
* @param {Object} b
* @returns {Number}
*/
sortByName(a, b) {
const nameA = a.name.toUpperCase();
const nameB = b.name.toUpperCase();
return nameA < nameB ? -1 : nameA > nameB ? 1 : 0; // eslint-disable-line
},
};
})();
...@@ -103,6 +103,9 @@ ...@@ -103,6 +103,9 @@
this.input.each((i, input) => { this.input.each((i, input) => {
const $input = $(input); const $input = $(input);
$input.off('focus.setupAtWho').on('focus.setupAtWho', this.setupAtWho.bind(this, $input)); $input.off('focus.setupAtWho').on('focus.setupAtWho', this.setupAtWho.bind(this, $input));
// This triggers at.js again
// Needed for slash commands with suffixes (ex: /label ~)
$input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup'));
}); });
}, },
setupAtWho: function($input) { setupAtWho: function($input) {
......
...@@ -42,11 +42,12 @@ ...@@ -42,11 +42,12 @@
calendar = new Pikaday({ calendar = new Pikaday({
field: $issuableDueDate.get(0), field: $issuableDueDate.get(0),
theme: 'gitlab-theme', theme: 'gitlab-theme',
format: 'YYYY-MM-DD', format: 'yyyy-mm-dd',
onSelect: function(dateText) { onSelect: function(dateText) {
$issuableDueDate.val(dateFormat(new Date(dateText), 'yyyy-mm-dd')); $issuableDueDate.val(dateFormat(new Date(dateText), 'yyyy-mm-dd'));
} }
}); });
calendar.setDate(new Date($issuableDueDate.val()));
} }
} }
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
require('./flash'); require('./flash');
require('vendor/jquery.waitforimages'); require('vendor/jquery.waitforimages');
require('vendor/task_list'); require('./task_list');
(function() { (function() {
var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
...@@ -11,10 +11,16 @@ require('vendor/task_list'); ...@@ -11,10 +11,16 @@ require('vendor/task_list');
this.Issue = (function() { this.Issue = (function() {
function Issue() { function Issue() {
this.submitNoteForm = bind(this.submitNoteForm, this); this.submitNoteForm = bind(this.submitNoteForm, this);
// Prevent duplicate event bindings
this.disableTaskList();
if ($('a.btn-close').length) { if ($('a.btn-close').length) {
this.initTaskList(); this.taskList = new gl.TaskList({
dataType: 'issue',
fieldName: 'description',
selector: '.detail-page-description',
onSuccess: (result) => {
document.querySelector('#task_status').innerText = result.task_status;
document.querySelector('#task_status_short').innerText = result.task_status_short;
}
});
this.initIssueBtnEventListeners(); this.initIssueBtnEventListeners();
} }
this.initMergeRequests(); this.initMergeRequests();
...@@ -22,11 +28,6 @@ require('vendor/task_list'); ...@@ -22,11 +28,6 @@ require('vendor/task_list');
this.initCanCreateBranch(); this.initCanCreateBranch();
} }
Issue.prototype.initTaskList = function() {
$('.detail-page-description .js-task-list-container').taskList('enable');
return $(document).on('tasklist:changed', '.detail-page-description .js-task-list-container', this.updateTaskList);
};
Issue.prototype.initIssueBtnEventListeners = function() { Issue.prototype.initIssueBtnEventListeners = function() {
var _this, issueFailMessage; var _this, issueFailMessage;
_this = this; _this = this;
...@@ -54,16 +55,19 @@ require('vendor/task_list'); ...@@ -54,16 +55,19 @@ require('vendor/task_list');
success: function(data, textStatus, jqXHR) { success: function(data, textStatus, jqXHR) {
if ('id' in data) { if ('id' in data) {
$(document).trigger('issuable:change'); $(document).trigger('issuable:change');
const currentTotal = Number($('.issue_counter').text());
if (isClose) { if (isClose) {
$('a.btn-close').addClass('hidden'); $('a.btn-close').addClass('hidden');
$('a.btn-reopen').removeClass('hidden'); $('a.btn-reopen').removeClass('hidden');
$('div.status-box-closed').removeClass('hidden'); $('div.status-box-closed').removeClass('hidden');
$('div.status-box-open').addClass('hidden'); $('div.status-box-open').addClass('hidden');
$('.issue_counter').text(currentTotal - 1);
} else { } else {
$('a.btn-reopen').addClass('hidden'); $('a.btn-reopen').addClass('hidden');
$('a.btn-close').removeClass('hidden'); $('a.btn-close').removeClass('hidden');
$('div.status-box-closed').addClass('hidden'); $('div.status-box-closed').addClass('hidden');
$('div.status-box-open').removeClass('hidden'); $('div.status-box-open').removeClass('hidden');
$('.issue_counter').text(currentTotal + 1);
} }
} else { } else {
new Flash(issueFailMessage, 'alert'); new Flash(issueFailMessage, 'alert');
...@@ -82,30 +86,6 @@ require('vendor/task_list'); ...@@ -82,30 +86,6 @@ require('vendor/task_list');
} }
}; };
Issue.prototype.disableTaskList = function() {
$('.detail-page-description .js-task-list-container').taskList('disable');
return $(document).off('tasklist:changed', '.detail-page-description .js-task-list-container');
};
Issue.prototype.updateTaskList = function() {
var patchData;
patchData = {};
patchData['issue'] = {
'description': $('.js-task-list-field', this).val()
};
return $.ajax({
type: 'PATCH',
url: $('form.js-issuable-update').attr('action'),
data: patchData,
success: function(issue) {
document.querySelector('#task_status').innerText = issue.task_status;
document.querySelector('#task_status_short').innerText = issue.task_status_short;
}
});
// TODO (rspeicher): Make the issue description inline-editable like a note so
// that we can re-use its form here
};
Issue.prototype.initMergeRequests = function() { Issue.prototype.initMergeRequests = function() {
var $container; var $container;
$container = $('#merge-requests'); $container = $('#merge-requests');
......
...@@ -231,6 +231,21 @@ ...@@ -231,6 +231,21 @@
return upperCaseHeaders; return upperCaseHeaders;
}; };
/**
* Parses pagination object string values into numbers.
*
* @param {Object} paginationInformation
* @returns {Object}
*/
w.gl.utils.parseIntPagination = paginationInformation => ({
perPage: parseInt(paginationInformation['X-PER-PAGE'], 10),
page: parseInt(paginationInformation['X-PAGE'], 10),
total: parseInt(paginationInformation['X-TOTAL'], 10),
totalPages: parseInt(paginationInformation['X-TOTAL-PAGES'], 10),
nextPage: parseInt(paginationInformation['X-NEXT-PAGE'], 10),
previousPage: parseInt(paginationInformation['X-PREV-PAGE'], 10),
});
/** /**
* Transforms a DOMStringMap into a plain object. * Transforms a DOMStringMap into a plain object.
* *
...@@ -241,5 +256,45 @@ ...@@ -241,5 +256,45 @@
acc[element] = DOMStringMapObject[element]; acc[element] = DOMStringMapObject[element];
return acc; return acc;
}, {}); }, {});
/**
* Updates the search parameter of a URL given the parameter and values provided.
*
* If no search params are present we'll add it.
* If param for page is already present, we'll update it
* If there are params but not for the given one, we'll add it at the end.
* Returns the new search parameters.
*
* @param {String} param
* @param {Number|String|Undefined|Null} value
* @return {String}
*/
w.gl.utils.setParamInURL = (param, value) => {
let search;
const locationSearch = window.location.search;
if (locationSearch.length === 0) {
search = `?${param}=${value}`;
}
if (locationSearch.indexOf(param) !== -1) {
const regex = new RegExp(param + '=\\d');
search = locationSearch.replace(regex, `${param}=${value}`);
}
if (locationSearch.length && locationSearch.indexOf(param) === -1) {
search = `${locationSearch}&${param}=${value}`;
}
return search;
};
/**
* Converts permission provided as strings to booleans.
*
* @param {String} string
* @returns {Boolean}
*/
w.gl.utils.convertPermissionToBoolean = permission => permission === 'true';
})(window); })(window);
}).call(this); }).call(this);
...@@ -19,7 +19,7 @@ ...@@ -19,7 +19,7 @@
const calendar = new Pikaday({ const calendar = new Pikaday({
field: $input.get(0), field: $input.get(0),
theme: 'gitlab-theme', theme: 'gitlab-theme',
format: 'YYYY-MM-DD', format: 'yyyy-mm-dd',
minDate: new Date(), minDate: new Date(),
onSelect(dateText) { onSelect(dateText) {
$input.val(dateFormat(new Date(dateText), 'yyyy-mm-dd')); $input.val(dateFormat(new Date(dateText), 'yyyy-mm-dd'));
...@@ -30,6 +30,7 @@ ...@@ -30,6 +30,7 @@
}, },
}); });
calendar.setDate(new Date($input.val()));
$input.data('pikaday', calendar); $input.data('pikaday', calendar);
}); });
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
/* global MergeRequestTabs */ /* global MergeRequestTabs */
require('vendor/jquery.waitforimages'); require('vendor/jquery.waitforimages');
require('vendor/task_list'); require('./task_list');
require('./merge_request_tabs'); require('./merge_request_tabs');
(function() { (function() {
...@@ -24,12 +24,18 @@ require('./merge_request_tabs'); ...@@ -24,12 +24,18 @@ require('./merge_request_tabs');
}; };
})(this)); })(this));
this.initTabs(); this.initTabs();
// Prevent duplicate event bindings
this.disableTaskList();
this.initMRBtnListeners(); this.initMRBtnListeners();
this.initCommitMessageListeners(); this.initCommitMessageListeners();
if ($("a.btn-close").length) { if ($("a.btn-close").length) {
this.initTaskList(); this.taskList = new gl.TaskList({
dataType: 'merge_request',
fieldName: 'description',
selector: '.detail-page-description',
onSuccess: (result) => {
document.querySelector('#task_status').innerText = result.task_status;
document.querySelector('#task_status_short').innerText = result.task_status_short;
}
});
} }
} }
...@@ -50,11 +56,6 @@ require('./merge_request_tabs'); ...@@ -50,11 +56,6 @@ require('./merge_request_tabs');
return this.$('.all-commits').removeClass('hide'); return this.$('.all-commits').removeClass('hide');
}; };
MergeRequest.prototype.initTaskList = function() {
$('.detail-page-description .js-task-list-container').taskList('enable');
return $(document).on('tasklist:changed', '.detail-page-description .js-task-list-container', this.updateTaskList);
};
MergeRequest.prototype.initMRBtnListeners = function() { MergeRequest.prototype.initMRBtnListeners = function() {
var _this; var _this;
_this = this; _this = this;
...@@ -85,30 +86,6 @@ require('./merge_request_tabs'); ...@@ -85,30 +86,6 @@ require('./merge_request_tabs');
} }
}; };
MergeRequest.prototype.disableTaskList = function() {
$('.detail-page-description .js-task-list-container').taskList('disable');
return $(document).off('tasklist:changed', '.detail-page-description .js-task-list-container');
};
MergeRequest.prototype.updateTaskList = function() {
var patchData;
patchData = {};
patchData['merge_request'] = {
'description': $('.js-task-list-field', this).val()
};
return $.ajax({
type: 'PATCH',
url: $('form.js-issuable-update').attr('action'),
data: patchData,
success: function(mergeRequest) {
document.querySelector('#task_status').innerText = mergeRequest.task_status;
document.querySelector('#task_status_short').innerText = mergeRequest.task_status_short;
}
});
// TODO (rspeicher): Make the merge request description inline-editable like a
// note so that we can re-use its form here
};
MergeRequest.prototype.initCommitMessageListeners = function() { MergeRequest.prototype.initCommitMessageListeners = function() {
$(document).on('click', 'a.js-with-description-link', function(e) { $(document).on('click', 'a.js-with-description-link', function(e) {
var textarea = $('textarea.js-commit-message'); var textarea = $('textarea.js-commit-message');
......
...@@ -103,9 +103,10 @@ require('./flash'); ...@@ -103,9 +103,10 @@ require('./flash');
} }
clickTab(e) { clickTab(e) {
if (e.target && gl.utils.isMetaClick(e)) { if (e.currentTarget && gl.utils.isMetaClick(e)) {
const targetLink = e.target.getAttribute('href'); const targetLink = e.currentTarget.getAttribute('href');
e.stopImmediatePropagation(); e.stopImmediatePropagation();
e.preventDefault();
window.open(targetLink, '_blank'); window.open(targetLink, '_blank');
} }
} }
......
...@@ -11,7 +11,7 @@ require('./dropzone_input'); ...@@ -11,7 +11,7 @@ require('./dropzone_input');
require('./gfm_auto_complete'); require('./gfm_auto_complete');
require('vendor/jquery.caret'); // required by jquery.atwho require('vendor/jquery.caret'); // required by jquery.atwho
require('vendor/jquery.atwho'); require('vendor/jquery.atwho');
require('vendor/task_list'); require('./task_list');
(function() { (function() {
var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
...@@ -51,7 +51,11 @@ require('vendor/task_list'); ...@@ -51,7 +51,11 @@ require('vendor/task_list');
this.addBinding(); this.addBinding();
this.setPollingInterval(); this.setPollingInterval();
this.setupMainTargetNoteForm(); this.setupMainTargetNoteForm();
this.initTaskList(); this.taskList = new gl.TaskList({
dataType: 'note',
fieldName: 'note',
selector: '.notes'
});
this.collapseLongCommitList(); this.collapseLongCommitList();
// We are in the Merge Requests page so we need another edit form for Changes tab // We are in the Merge Requests page so we need another edit form for Changes tab
...@@ -125,8 +129,6 @@ require('vendor/task_list'); ...@@ -125,8 +129,6 @@ require('vendor/task_list');
$(document).off("keydown", ".js-note-text"); $(document).off("keydown", ".js-note-text");
$(document).off('click', '.js-comment-resolve-button'); $(document).off('click', '.js-comment-resolve-button');
$(document).off("click", '.system-note-commit-list-toggler'); $(document).off("click", '.system-note-commit-list-toggler');
$('.note .js-task-list-container').taskList('disable');
return $(document).off('tasklist:changed', '.note .js-task-list-container');
}; };
Notes.prototype.keydownNoteText = function(e) { Notes.prototype.keydownNoteText = function(e) {
...@@ -286,7 +288,7 @@ require('vendor/task_list'); ...@@ -286,7 +288,7 @@ require('vendor/task_list');
// Update datetime format on the recent note // Update datetime format on the recent note
gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false); gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false);
this.collapseLongCommitList(); this.collapseLongCommitList();
this.initTaskList(); this.taskList.init();
this.refresh(); this.refresh();
return this.updateNotesCount(1); return this.updateNotesCount(1);
} }
...@@ -863,15 +865,6 @@ require('vendor/task_list'); ...@@ -863,15 +865,6 @@ require('vendor/task_list');
} }
}; };
Notes.prototype.initTaskList = function() {
this.enableTaskList();
return $(document).on('tasklist:changed', '.note .js-task-list-container', this.updateTaskList.bind(this));
};
Notes.prototype.enableTaskList = function() {
return $('.note .js-task-list-container').taskList('enable');
};
Notes.prototype.putEditFormInPlace = function($el) { Notes.prototype.putEditFormInPlace = function($el) {
var $editForm = $(this.getEditFormSelector($el)); var $editForm = $(this.getEditFormSelector($el));
var $note = $el.closest('.note'); var $note = $el.closest('.note');
...@@ -896,17 +889,6 @@ require('vendor/task_list'); ...@@ -896,17 +889,6 @@ require('vendor/task_list');
$editForm.find('.referenced-users').hide(); $editForm.find('.referenced-users').hide();
}; };
Notes.prototype.updateTaskList = function(e) {
var $target = $(e.target);
var $list = $target.closest('.js-task-list-container');
var $editForm = $(this.getEditFormSelector($target));
var $note = $list.closest('.note');
this.putEditFormInPlace($list);
$editForm.find('#note_note').val($note.find('.original-task-list').val());
$('form', $list).submit();
};
Notes.prototype.updateNotesCount = function(updateCount) { Notes.prototype.updateNotesCount = function(updateCount) {
return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount); return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount);
}; };
...@@ -923,9 +905,10 @@ require('vendor/task_list'); ...@@ -923,9 +905,10 @@ require('vendor/task_list');
}; };
Notes.prototype.toggleCommitList = function(e) { Notes.prototype.toggleCommitList = function(e) {
const $element = $(e.target); const $element = $(e.currentTarget);
const $closestSystemCommitList = $element.siblings('.system-note-commit-list'); const $closestSystemCommitList = $element.siblings('.system-note-commit-list');
$element.find('.fa').toggleClass('fa-angle-down').toggleClass('fa-angle-up');
$closestSystemCommitList.toggleClass('hide-shade'); $closestSystemCommitList.toggleClass('hide-shade');
}; };
......
...@@ -38,13 +38,15 @@ ...@@ -38,13 +38,15 @@
this.$buttons.attr('data-status', newStatus); this.$buttons.attr('data-status', newStatus);
this.$buttons.find('> span').text(newAction); this.$buttons.find('> span').text(newAction);
for (const button of this.$buttons) { this.$buttons.map((button) => {
const $button = $(button); const $button = $(button);
if ($button.attr('data-original-title')) { if ($button.attr('data-original-title')) {
$button.tooltip('hide').attr('data-original-title', newAction).tooltip('fixTitle'); $button.tooltip('hide').attr('data-original-title', newAction).tooltip('fixTitle');
} }
}
return button;
});
}); });
} }
} }
......
...@@ -21,11 +21,16 @@ ...@@ -21,11 +21,16 @@
}; };
Sidebar.prototype.addEventListeners = function() { Sidebar.prototype.addEventListeners = function() {
const $document = $(document);
const throttledSetSidebarHeight = _.throttle(this.setSidebarHeight, 10);
this.sidebar.on('click', '.sidebar-collapsed-icon', this, this.sidebarCollapseClicked); this.sidebar.on('click', '.sidebar-collapsed-icon', this, this.sidebarCollapseClicked);
$('.dropdown').on('hidden.gl.dropdown', this, this.onSidebarDropdownHidden); $('.dropdown').on('hidden.gl.dropdown', this, this.onSidebarDropdownHidden);
$('.dropdown').on('loading.gl.dropdown', this.sidebarDropdownLoading); $('.dropdown').on('loading.gl.dropdown', this.sidebarDropdownLoading);
$('.dropdown').on('loaded.gl.dropdown', this.sidebarDropdownLoaded); $('.dropdown').on('loaded.gl.dropdown', this.sidebarDropdownLoaded);
$(document).on('click', '.js-sidebar-toggle', function(e, triggered) { $(window).on('resize', () => throttledSetSidebarHeight());
$document.on('scroll', () => throttledSetSidebarHeight());
$document.on('click', '.js-sidebar-toggle', function(e, triggered) {
var $allGutterToggleIcons, $this, $thisIcon; var $allGutterToggleIcons, $this, $thisIcon;
e.preventDefault(); e.preventDefault();
$this = $(this); $this = $(this);
...@@ -191,6 +196,17 @@ ...@@ -191,6 +196,17 @@
} }
}; };
Sidebar.prototype.setSidebarHeight = function() {
const $navHeight = $('.navbar-gitlab').outerHeight() + $('.layout-nav').outerHeight();
const $rightSidebar = $('.js-right-sidebar');
const diff = $navHeight - $('body').scrollTop();
if (diff > 0) {
$rightSidebar.outerHeight($(window).height() - diff);
} else {
$rightSidebar.outerHeight('100%');
}
};
Sidebar.prototype.isOpen = function() { Sidebar.prototype.isOpen = function() {
return this.sidebar.is('.right-sidebar-expanded'); return this.sidebar.is('.right-sidebar-expanded');
}; };
......
/* eslint-disable arrow-parens, class-methods-use-this, no-param-reassign */
/* global Cookies */
(() => {
const pinnedStateCookie = 'pin_nav';
const sidebarBreakpoint = 1024;
const pageSelector = '.page-with-sidebar';
const navbarSelector = '.navbar-gitlab';
const sidebarWrapperSelector = '.sidebar-wrapper';
const sidebarContentSelector = '.nav-sidebar';
const pinnedToggleSelector = '.js-nav-pin';
const sidebarToggleSelector = '.toggle-nav-collapse, .side-nav-toggle';
const pinnedPageClass = 'page-sidebar-pinned';
const expandedPageClass = 'page-sidebar-expanded';
const pinnedNavbarClass = 'header-sidebar-pinned';
const expandedNavbarClass = 'header-sidebar-expanded';
class Sidebar {
constructor() {
if (!Sidebar.singleton) {
Sidebar.singleton = this;
Sidebar.singleton.init();
}
return Sidebar.singleton;
}
init() {
this.isPinned = Cookies.get(pinnedStateCookie) === 'true';
this.isExpanded = (
window.innerWidth >= sidebarBreakpoint &&
$(pageSelector).hasClass(expandedPageClass)
);
$(window).on('resize', () => this.setSidebarHeight());
$(document)
.on('click', sidebarToggleSelector, () => this.toggleSidebar())
.on('click', pinnedToggleSelector, () => this.togglePinnedState())
.on('click', 'html, body, a, button', (e) => this.handleClickEvent(e))
.on('DOMContentLoaded', () => this.renderState())
.on('scroll', () => this.setSidebarHeight())
.on('todo:toggle', (e, count) => this.updateTodoCount(count));
this.renderState();
this.setSidebarHeight();
}
handleClickEvent(e) {
if (this.isExpanded && (!this.isPinned || window.innerWidth < sidebarBreakpoint)) {
const $target = $(e.target);
const targetIsToggle = $target.closest(sidebarToggleSelector).length > 0;
const targetIsSidebar = $target.closest(sidebarWrapperSelector).length > 0;
if (!targetIsToggle && (!targetIsSidebar || $target.closest('a'))) {
this.toggleSidebar();
}
}
}
updateTodoCount(count) {
$('.js-todos-count').text(gl.text.addDelimiter(count));
}
toggleSidebar() {
this.isExpanded = !this.isExpanded;
this.renderState();
}
setSidebarHeight() {
const $navHeight = $('.navbar-gitlab').outerHeight() + $('.layout-nav').outerHeight();
const diff = $navHeight - $('body').scrollTop();
if (diff > 0) {
$('.js-right-sidebar').outerHeight($(window).height() - diff);
} else {
$('.js-right-sidebar').outerHeight('100%');
}
}
togglePinnedState() {
this.isPinned = !this.isPinned;
if (!this.isPinned) {
this.isExpanded = false;
}
Cookies.set(pinnedStateCookie, this.isPinned ? 'true' : 'false', { expires: 3650 });
this.renderState();
}
renderState() {
$(pageSelector)
.toggleClass(pinnedPageClass, this.isPinned && this.isExpanded)
.toggleClass(expandedPageClass, this.isExpanded);
$(navbarSelector)
.toggleClass(pinnedNavbarClass, this.isPinned && this.isExpanded)
.toggleClass(expandedNavbarClass, this.isExpanded);
const $pinnedToggle = $(pinnedToggleSelector);
const tooltipText = this.isPinned ? 'Unpin navigation' : 'Pin navigation';
const tooltipState = $pinnedToggle.attr('aria-describedby') && this.isExpanded ? 'show' : 'hide';
$pinnedToggle.attr('title', tooltipText).tooltip('fixTitle').tooltip(tooltipState);
if (this.isExpanded) {
const sidebarContent = $(sidebarContentSelector);
setTimeout(() => { sidebarContent.niceScroll().updateScrollBar(); }, 200);
}
}
}
window.gl = window.gl || {};
gl.Sidebar = Sidebar;
})();
require('vendor/task_list');
class TaskList {
constructor(options = {}) {
this.selector = options.selector;
this.dataType = options.dataType;
this.fieldName = options.fieldName;
this.onSuccess = options.onSuccess || (() => {});
this.init();
}
init() {
// Prevent duplicate event bindings
this.disable();
$(`${this.selector} .js-task-list-container`).taskList('enable');
$(document).on('tasklist:changed', `${this.selector} .js-task-list-container`, this.update.bind(this));
}
disable() {
$(`${this.selector} .js-task-list-container`).taskList('disable');
$(document).off('tasklist:changed', `${this.selector} .js-task-list-container`);
}
update(e) {
const $target = $(e.target);
const patchData = {};
patchData[this.dataType] = {
[this.fieldName]: $target.val(),
};
return $.ajax({
type: 'PATCH',
url: $target.data('update-url') || $('form.js-issuable-update').attr('action'),
data: patchData,
success: this.onSuccess,
});
}
}
window.gl = window.gl || {};
window.gl.TaskList = TaskList;
...@@ -147,24 +147,21 @@ ...@@ -147,24 +147,21 @@
goToTodoUrl(e) { goToTodoUrl(e) {
const todoLink = this.dataset.url; const todoLink = this.dataset.url;
let targetLink = e.target.getAttribute('href');
if (e.target.tagName === 'IMG') { // See if clicked target was Avatar
targetLink = e.target.parentElement.getAttribute('href'); // Parent of Avatar is link
}
if (!todoLink) { if (!todoLink) {
return; return;
} }
if (gl.utils.isMetaClick(e)) { if (gl.utils.isMetaClick(e)) {
const windowTarget = '_blank';
const selected = e.target;
e.preventDefault(); e.preventDefault();
// Meta-Click on username leads to different URL than todoLink.
// Turbolinks can resolve that URL, but window.open requires URL manually. if (selected.tagName === 'IMG') {
if (targetLink !== todoLink) { const avatarUrl = selected.parentElement.getAttribute('href');
return window.open(targetLink, '_blank'); return window.open(avatarUrl, windowTarget);
} else { } else {
return window.open(todoLink, '_blank'); return window.open(todoLink, windowTarget);
} }
} else { } else {
return gl.utils.visitUrl(todoLink); return gl.utils.visitUrl(todoLink);
......
...@@ -62,6 +62,7 @@ ...@@ -62,6 +62,7 @@
<li v-for='artifact in pipeline.details.artifacts'> <li v-for='artifact in pipeline.details.artifacts'>
<a <a
rel="nofollow" rel="nofollow"
download
:href='artifact.path' :href='artifact.path'
> >
<i class="fa fa-download" aria-hidden="true"></i> <i class="fa fa-download" aria-hidden="true"></i>
......
...@@ -5,6 +5,7 @@ window.Vue = require('vue'); ...@@ -5,6 +5,7 @@ window.Vue = require('vue');
require('../vue_shared/components/table_pagination'); require('../vue_shared/components/table_pagination');
require('./store'); require('./store');
require('../vue_shared/components/pipelines_table'); require('../vue_shared/components/pipelines_table');
const CommitPipelinesStoreWithTimeAgo = require('../commit/pipelines/pipelines_store');
((gl) => { ((gl) => {
gl.VuePipelines = Vue.extend({ gl.VuePipelines = Vue.extend({
...@@ -28,15 +29,34 @@ require('../vue_shared/components/pipelines_table'); ...@@ -28,15 +29,34 @@ require('../vue_shared/components/pipelines_table');
}, },
props: ['scope', 'store', 'svgs'], props: ['scope', 'store', 'svgs'],
created() { created() {
const pagenum = gl.utils.getParameterByName('p'); const pagenum = gl.utils.getParameterByName('page');
const scope = gl.utils.getParameterByName('scope'); const scope = gl.utils.getParameterByName('scope');
if (pagenum) this.pagenum = pagenum; if (pagenum) this.pagenum = pagenum;
if (scope) this.apiScope = scope; if (scope) this.apiScope = scope;
this.store.fetchDataLoop.call(this, Vue, this.pagenum, this.scope, this.apiScope); this.store.fetchDataLoop.call(this, Vue, this.pagenum, this.scope, this.apiScope);
}, },
beforeUpdate() {
if (this.pipelines.length && this.$children) {
CommitPipelinesStoreWithTimeAgo.startTimeAgoLoops.call(this, Vue);
}
},
methods: { methods: {
/**
* Changes the URL according to the pagination component.
*
* If no scope is provided, 'all' is assumed.
*
* Pagination component sends "null" when no scope is provided.
*
* @param {Number} pagenum
* @param {String} apiScope = 'all'
*/
change(pagenum, apiScope) { change(pagenum, apiScope) {
gl.utils.visitUrl(`?scope=${apiScope}&p=${pagenum}`); if (!apiScope) apiScope = 'all';
gl.utils.visitUrl(`?scope=${apiScope}&page=${pagenum}`);
}, },
}, },
template: ` template: `
......
/* global gl, Flash */ /* global gl, Flash */
/* eslint-disable no-param-reassign, no-underscore-dangle */ /* eslint-disable no-param-reassign */
require('../vue_realtime_listener');
((gl) => { ((gl) => {
const pageValues = (headers) => { const pageValues = (headers) => {
const normalized = gl.utils.normalizeHeaders(headers); const normalized = gl.utils.normalizeHeaders(headers);
const paginationInfo = gl.utils.parseIntPagination(normalized);
const paginationInfo = {
perPage: +normalized['X-PER-PAGE'],
page: +normalized['X-PAGE'],
total: +normalized['X-TOTAL'],
totalPages: +normalized['X-TOTAL-PAGES'],
nextPage: +normalized['X-NEXT-PAGE'],
previousPage: +normalized['X-PREV-PAGE'],
};
return paginationInfo; return paginationInfo;
}; };
gl.PipelineStore = class { gl.PipelineStore = class {
fetchDataLoop(Vue, pageNum, url, apiScope) { fetchDataLoop(Vue, pageNum, url, apiScope) {
this.pageRequest = true; this.pageRequest = true;
const updatePipelineNums = (count) => {
const { all } = count;
const running = count.running_or_pending;
document.querySelector('.js-totalbuilds-count').innerHTML = all;
document.querySelector('.js-running-count').innerHTML = running;
};
const goFetch = () =>
this.$http.get(`${url}?scope=${apiScope}&page=${pageNum}`)
.then((response) => {
const pageInfo = pageValues(response.headers);
this.pageInfo = Object.assign({}, this.pageInfo, pageInfo);
const res = JSON.parse(response.body);
this.count = Object.assign({}, this.count, res.count);
this.pipelines = Object.assign([], this.pipelines, res.pipelines);
updatePipelineNums(this.count);
this.pageRequest = false;
}, () => {
this.pageRequest = false;
return new Flash('An error occurred while fetching the pipelines, please reload the page again.');
});
goFetch();
const startTimeLoops = () => {
this.timeLoopInterval = setInterval(() => {
this.$children[0].$children.reduce((acc, component) => {
const timeAgoComponent = component.$children.filter(el => el.$options._componentTag === 'time-ago')[0];
acc.push(timeAgoComponent);
return acc;
}, []).forEach(e => e.changeTime());
}, 10000);
};
startTimeLoops(); return this.$http.get(`${url}?scope=${apiScope}&page=${pageNum}`)
.then((response) => {
const pageInfo = pageValues(response.headers);
this.pageInfo = Object.assign({}, this.pageInfo, pageInfo);
const removeIntervals = () => clearInterval(this.timeLoopInterval); const res = JSON.parse(response.body);
const startIntervals = () => startTimeLoops(); this.count = Object.assign({}, this.count, res.count);
this.pipelines = Object.assign([], this.pipelines, res.pipelines);
gl.VueRealtimeListener(removeIntervals, startIntervals); this.pageRequest = false;
}, () => {
this.pageRequest = false;
return new Flash('An error occurred while fetching the pipelines, please reload the page again.');
});
} }
}; };
})(window.gl || (window.gl = {})); })(window.gl || (window.gl = {}));
/* global Vue */ /* global Vue */
window.Vue = require('vue');
(() => { (() => {
window.gl = window.gl || {}; window.gl = window.gl || {};
......
...@@ -57,9 +57,7 @@ window.Vue = require('vue'); ...@@ -57,9 +57,7 @@ window.Vue = require('vue');
}, },
methods: { methods: {
changePage(e) { changePage(e) {
let apiScope = gl.utils.getParameterByName('scope'); const apiScope = gl.utils.getParameterByName('scope');
if (!apiScope) apiScope = 'all';
const text = e.target.innerText; const text = e.target.innerText;
const { totalPages, nextPage, previousPage } = this.pageInfo; const { totalPages, nextPage, previousPage } = this.pageInfo;
......
...@@ -19,7 +19,6 @@ ...@@ -19,7 +19,6 @@
@import "framework/flash.scss"; @import "framework/flash.scss";
@import "framework/forms.scss"; @import "framework/forms.scss";
@import "framework/gfm.scss"; @import "framework/gfm.scss";
@import "framework/gitlab-theme.scss";
@import "framework/header.scss"; @import "framework/header.scss";
@import "framework/highlight.scss"; @import "framework/highlight.scss";
@import "framework/issue_box.scss"; @import "framework/issue_box.scss";
......
...@@ -116,7 +116,7 @@ ...@@ -116,7 +116,7 @@
} }
.btn, .btn,
.side-nav-toggle { .global-dropdown-toggle {
@include transition(background-color, border-color, color, box-shadow); @include transition(background-color, border-color, color, box-shadow);
} }
...@@ -140,7 +140,6 @@ a { ...@@ -140,7 +140,6 @@ a {
@include transition(background-color, box-shadow); @include transition(background-color, box-shadow);
} }
.nav-sidebar a,
.dropdown-menu a, .dropdown-menu a,
.dropdown-menu button, .dropdown-menu button,
.dropdown-menu-nav a { .dropdown-menu-nav a {
......
/**
* Styles the GitLab application with a specific color theme
*
* $color-light -
* $color -
* $color-darker -
* $color-dark -
*/
@mixin gitlab-theme($color-light, $color, $color-darker, $color-dark) {
.page-with-sidebar {
.toggle-nav-collapse,
.pin-nav-btn {
color: $color-light;
&:hover {
color: $white-light;
}
}
.sidebar-wrapper {
background: $color-darker;
}
.sidebar-action-buttons {
color: $color-light;
background-color: lighten($color-darker, 5%);
}
.nav-sidebar {
li {
a {
color: $color-light;
&:hover,
&:focus,
&:active {
background: $color-dark;
}
i {
color: $color-light;
}
path,
polygon {
fill: $color-light;
}
.count {
color: $color-light;
background: $color-dark;
}
svg {
position: relative;
top: 3px;
}
}
&.separate-item {
border-top: 1px solid $color;
}
&.active a {
color: $white-light;
background: $color-dark;
&.no-highlight {
border: none;
}
i {
color: $white-light;
}
path,
polygon {
fill: $white-light;
}
}
}
.about-gitlab {
color: $color-light;
}
}
}
}
$theme-charcoal-light: #b9bbbe;
$theme-charcoal: #485157;
$theme-charcoal-dark: #3d454d;
$theme-charcoal-darker: #383f45;
$theme-blue-light: #becde9;
$theme-blue: #2980b9;
$theme-blue-dark: #1970a9;
$theme-blue-darker: #096099;
$theme-graphite-light: #ccc;
$theme-graphite: #777;
$theme-graphite-dark: #666;
$theme-graphite-darker: #555;
$theme-black-light: #979797;
$theme-black: #373737;
$theme-black-dark: #272727;
$theme-black-darker: #222;
$theme-green-light: #adc;
$theme-green: #019875;
$theme-green-dark: #018865;
$theme-green-darker: #017855;
$theme-violet-light: #98c;
$theme-violet: #548;
$theme-violet-dark: #436;
$theme-violet-darker: #325;
body {
&.ui_blue {
@include gitlab-theme($theme-blue-light, $theme-blue, $theme-blue-dark, $theme-blue-darker);
}
&.ui_charcoal {
@include gitlab-theme($theme-charcoal-light, $theme-charcoal, $theme-charcoal-dark, $theme-charcoal-darker);
}
&.ui_graphite {
@include gitlab-theme($theme-graphite-light, $theme-graphite, $theme-graphite-dark, $theme-graphite-darker);
}
&.ui_black {
@include gitlab-theme($theme-black-light, $theme-black, $theme-black-dark, $theme-black-darker);
}
&.ui_green {
@include gitlab-theme($theme-green-light, $theme-green, $theme-green-dark, $theme-green-darker);
}
&.ui_violet {
@include gitlab-theme($theme-violet-light, $theme-violet, $theme-violet-dark, $theme-violet-darker);
}
}
...@@ -100,23 +100,42 @@ header { ...@@ -100,23 +100,42 @@ header {
} }
} }
} }
}
.side-nav-toggle { .global-dropdown {
position: absolute; position: absolute;
left: -10px; left: -10px;
margin: 7px 0;
font-size: 18px; .badge {
padding: 6px 10px; font-size: 11px;
border: none; }
background-color: $gray-light;
li {
.active a {
font-weight: bold;
}
&:hover { &:hover {
background-color: $white-normal; .badge {
color: $gl-header-nav-hover-color; background-color: $white-light;
}
} }
} }
} }
.global-dropdown-toggle {
margin: 7px 0;
font-size: 18px;
padding: 6px 10px;
border: none;
background-color: $gray-light;
&:hover {
background-color: $white-normal;
color: $gl-header-nav-hover-color;
}
}
.header-content { .header-content {
position: relative; position: relative;
height: $header-height; height: $header-height;
......
.page-with-sidebar {
padding-bottom: 25px;
transition: padding $sidebar-transition-duration;
&.page-sidebar-pinned {
.sidebar-wrapper {
box-shadow: none;
}
}
.sidebar-wrapper {
position: fixed;
top: 0;
bottom: 0;
left: 0;
height: 100%;
width: 0;
overflow: hidden;
transition: width $sidebar-transition-duration;
box-shadow: 2px 0 16px 0 $black-transparent;
}
}
.sidebar-wrapper {
z-index: 1000;
background: $gray-light;
.nicescroll-rails-hr {
// TODO: Figure out why nicescroll doesn't hide horizontal bar
display: none!important;
}
}
.content-wrapper { .content-wrapper {
width: 100%; width: 100%;
transition: padding $sidebar-transition-duration; transition: padding $sidebar-transition-duration;
...@@ -47,105 +14,6 @@ ...@@ -47,105 +14,6 @@
} }
} }
.nav-sidebar {
position: absolute;
top: 50px;
bottom: 0;
width: $sidebar_width;
overflow-y: auto;
overflow-x: hidden;
&.navbar-collapse {
padding: 0 !important;
}
li {
&.separate-item {
padding-top: 10px;
margin-top: 10px;
}
.icon-container {
width: 34px;
display: inline-block;
text-align: center;
}
a {
padding: 7px $gl-sidebar-padding;
font-size: $gl-font-size;
line-height: 24px;
display: block;
text-decoration: none;
font-weight: normal;
&:hover,
&:active,
&:focus {
text-decoration: none;
}
i {
font-size: 16px;
}
i,
svg {
margin-right: 13px;
}
}
}
.count {
float: right;
padding: 0 8px;
border-radius: 6px;
}
.about-gitlab {
padding: 7px $gl-sidebar-padding;
font-size: $gl-font-size;
line-height: 24px;
display: block;
text-decoration: none;
font-weight: normal;
position: absolute;
bottom: 10px;
}
}
.sidebar-action-buttons {
width: $sidebar_width;
position: absolute;
top: 0;
left: 0;
min-height: 50px;
padding: 5px 0;
font-size: 18px;
line-height: 30px;
.toggle-nav-collapse {
left: 0;
}
.pin-nav-btn {
right: 0;
display: none;
@media (min-width: $sidebar-breakpoint) {
display: block;
}
.fa {
transition: transform .15s;
.page-sidebar-pinned & {
transform: rotate(90deg);
}
}
}
}
.nav-header-btn { .nav-header-btn {
padding: 10px $gl-sidebar-padding; padding: 10px $gl-sidebar-padding;
color: inherit; color: inherit;
...@@ -161,59 +29,16 @@ ...@@ -161,59 +29,16 @@
} }
} }
.page-sidebar-expanded {
.sidebar-wrapper {
width: $sidebar_width;
}
}
.page-sidebar-pinned {
.content-wrapper,
.layout-nav {
@media (min-width: $sidebar-breakpoint) {
padding-left: $sidebar_width;
}
}
.merge-request-tabs-holder.affix {
@media (min-width: $sidebar-breakpoint) {
left: $sidebar_width;
}
}
&.right-sidebar-expanded {
.line-resolve-all-container {
@media (min-width: $sidebar-breakpoint) {
display: none;
}
}
}
}
header.header-sidebar-pinned {
@media (min-width: $sidebar-breakpoint) {
padding-left: ($sidebar_width + $gl-padding);
.side-nav-toggle {
display: none;
}
.header-content {
padding-left: 0;
}
}
}
.right-sidebar-collapsed { .right-sidebar-collapsed {
padding-right: 0; padding-right: 0;
@media (min-width: $screen-sm-min) { @media (min-width: $screen-sm-min) {
.content-wrapper { .content-wrapper {
padding-right: $sidebar_collapsed_width; padding-right: $gutter_collapsed_width;
} }
.merge-request-tabs-holder.affix { .merge-request-tabs-holder.affix {
right: $sidebar_collapsed_width; right: $gutter_collapsed_width;
} }
} }
...@@ -231,7 +56,7 @@ header.header-sidebar-pinned { ...@@ -231,7 +56,7 @@ header.header-sidebar-pinned {
@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
&:not(.build-sidebar):not(.wiki-sidebar) { &:not(.build-sidebar):not(.wiki-sidebar) {
padding-right: $sidebar_collapsed_width; padding-right: $gutter_collapsed_width;
} }
} }
...@@ -245,12 +70,12 @@ header.header-sidebar-pinned { ...@@ -245,12 +70,12 @@ header.header-sidebar-pinned {
} }
&.with-overlay .merge-request-tabs-holder.affix { &.with-overlay .merge-request-tabs-holder.affix {
right: $sidebar_collapsed_width; right: $gutter_collapsed_width;
} }
} }
&.with-overlay { &.with-overlay {
padding-right: $sidebar_collapsed_width; padding-right: $gutter_collapsed_width;
} }
} }
......
/* /*
* Layout * Layout
*/ */
$sidebar_collapsed_width: 62px;
$sidebar_width: 220px;
$gutter_collapsed_width: 62px; $gutter_collapsed_width: 62px;
$gutter_width: 290px; $gutter_width: 290px;
$gutter_inner_width: 250px; $gutter_inner_width: 250px;
......
...@@ -91,7 +91,7 @@ ...@@ -91,7 +91,7 @@
} }
&.scroll-top { &.scroll-top {
top: 110px; top: 10px;
} }
&.scroll-bottom { &.scroll-bottom {
......
...@@ -10,6 +10,11 @@ ...@@ -10,6 +10,11 @@
font-size: 34px; font-size: 34px;
} }
.environments-folder-name {
font-weight: normal;
padding-top: 20px;
}
@media (max-width: $screen-xs-max) { @media (max-width: $screen-xs-max) {
.environments-container { .environments-container {
width: 100%; width: 100%;
...@@ -110,17 +115,20 @@ ...@@ -110,17 +115,20 @@
} }
} }
.children-row .environment-name {
margin-left: 17px;
margin-right: -17px;
}
.folder-icon { .folder-icon {
padding: 0 5px 0 0; margin-right: 3px;
color: $gl-text-color-secondary;
display: inline-block;
.fa:nth-child(1) {
margin-right: 3px;
}
} }
.folder-name { .folder-name {
cursor: pointer; cursor: pointer;
color: $gl-text-color-secondary;
display: inline-block;
} }
} }
...@@ -135,4 +143,4 @@ ...@@ -135,4 +143,4 @@
margin-right: 0; margin-right: 0;
} }
} }
} }
\ No newline at end of file
...@@ -253,11 +253,11 @@ ...@@ -253,11 +253,11 @@
display: block; display: block;
} }
width: $sidebar_collapsed_width; width: $gutter_collapsed_width;
padding-top: 0; padding-top: 0;
.block { .block {
width: $sidebar_collapsed_width - 2px; width: $gutter_collapsed_width - 2px;
margin-left: -19px; margin-left: -19px;
padding: 15px 0 0; padding: 15px 0 0;
border-bottom: none; border-bottom: none;
......
...@@ -72,6 +72,7 @@ ul.notes { ...@@ -72,6 +72,7 @@ ul.notes {
overflow: hidden; overflow: hidden;
.system-note-commit-list-toggler { .system-note-commit-list-toggler {
color: $gl-link-color;
display: none; display: none;
padding: 10px 0 0; padding: 10px 0 0;
cursor: pointer; cursor: pointer;
...@@ -107,16 +108,6 @@ ul.notes { ...@@ -107,16 +108,6 @@ ul.notes {
display: none; display: none;
} }
p:last-child {
a {
color: $gl-text-color;
&:hover {
color: $gl-link-color;
}
}
}
&::after { &::after {
content: ''; content: '';
width: 100%; width: 100%;
......
.application-theme {
label {
margin-right: 20px;
text-align: center;
.preview {
border-radius: 4px;
height: 80px;
margin-bottom: 10px;
width: 160px;
&.ui_blue {
background: $theme-blue;
}
&.ui_charcoal {
background: $theme-charcoal;
}
&.ui_graphite {
background: $theme-graphite;
}
&.ui_black {
background: $theme-black;
}
&.ui_green {
background: $theme-green;
}
&.ui_violet {
background: $theme-violet;
}
}
}
}
.syntax-theme { .syntax-theme {
label { label {
margin-right: 20px; margin-right: 20px;
......
...@@ -102,6 +102,7 @@ ...@@ -102,6 +102,7 @@
font-size: 24px; font-size: 24px;
font-weight: 400; font-weight: 400;
line-height: 1; line-height: 1;
word-wrap: break-word;
.fa { .fa {
margin-left: 2px; margin-left: 2px;
......
...@@ -149,7 +149,7 @@ ...@@ -149,7 +149,7 @@
} }
.commit-actions { .commit-actions {
width: 200px; width: 260px;
} }
} }
......
...@@ -31,7 +31,6 @@ nav.navbar-collapse.collapse, ...@@ -31,7 +31,6 @@ nav.navbar-collapse.collapse,
.blob-commit-info, .blob-commit-info,
.file-title, .file-title,
.file-holder, .file-holder,
.sidebar-wrapper,
.nav, .nav,
.btn, .btn,
ul.notes-form, ul.notes-form,
......
...@@ -13,7 +13,7 @@ class Admin::RunnersController < Admin::ApplicationController ...@@ -13,7 +13,7 @@ class Admin::RunnersController < Admin::ApplicationController
end end
def update def update
if @runner.update_attributes(runner_params) if Ci::UpdateRunnerService.new(@runner).update(runner_params)
respond_to do |format| respond_to do |format|
format.js format.js
format.html { redirect_to admin_runner_path(@runner) } format.html { redirect_to admin_runner_path(@runner) }
...@@ -31,7 +31,7 @@ class Admin::RunnersController < Admin::ApplicationController ...@@ -31,7 +31,7 @@ class Admin::RunnersController < Admin::ApplicationController
end end
def resume def resume
if @runner.update_attributes(active: true) if Ci::UpdateRunnerService.new(@runner).update(active: true)
redirect_to admin_runners_path, notice: 'Runner was successfully updated.' redirect_to admin_runners_path, notice: 'Runner was successfully updated.'
else else
redirect_to admin_runners_path, alert: 'Runner was not updated.' redirect_to admin_runners_path, alert: 'Runner was not updated.'
...@@ -39,7 +39,7 @@ class Admin::RunnersController < Admin::ApplicationController ...@@ -39,7 +39,7 @@ class Admin::RunnersController < Admin::ApplicationController
end end
def pause def pause
if @runner.update_attributes(active: false) if Ci::UpdateRunnerService.new(@runner).update(active: false)
redirect_to admin_runners_path, notice: 'Runner was successfully updated.' redirect_to admin_runners_path, notice: 'Runner was successfully updated.'
else else
redirect_to admin_runners_path, alert: 'Runner was not updated.' redirect_to admin_runners_path, alert: 'Runner was not updated.'
......
...@@ -194,7 +194,6 @@ class Admin::UsersController < Admin::ApplicationController ...@@ -194,7 +194,6 @@ class Admin::UsersController < Admin::ApplicationController
:provider, :provider,
:remember_me, :remember_me,
:skype, :skype,
:theme_id,
:twitter, :twitter,
:username, :username,
:website_url :website_url
......
...@@ -34,7 +34,6 @@ class Profiles::PreferencesController < Profiles::ApplicationController ...@@ -34,7 +34,6 @@ class Profiles::PreferencesController < Profiles::ApplicationController
:layout, :layout,
:dashboard, :dashboard,
:project_view, :project_view,
:theme_id
) )
end end
end end
...@@ -83,7 +83,6 @@ class Projects::ApplicationController < ApplicationController ...@@ -83,7 +83,6 @@ class Projects::ApplicationController < ApplicationController
end end
def apply_diff_view_cookie! def apply_diff_view_cookie!
@show_changes_tab = params[:view].present?
cookies.permanent[:diff_view] = params.delete(:view) if params[:view].present? cookies.permanent[:diff_view] = params.delete(:view) if params[:view].present?
end end
......
...@@ -9,15 +9,40 @@ class Projects::EnvironmentsController < Projects::ApplicationController ...@@ -9,15 +9,40 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :verify_api_request!, only: :terminal_websocket_authorize before_action :verify_api_request!, only: :terminal_websocket_authorize
def index def index
@scope = params[:scope] @environments = project.environments
@environments = project.environments.includes(:last_deployment) .with_state(params[:scope] || :available)
respond_to do |format| respond_to do |format|
format.html format.html
format.json do format.json do
render json: EnvironmentSerializer render json: {
.new(project: @project, user: current_user) environments: EnvironmentSerializer
.represent(@environments) .new(project: @project, user: @current_user)
.with_pagination(request, response)
.within_folders
.represent(@environments),
available_count: project.environments.available.count,
stopped_count: project.environments.stopped.count
}
end
end
end
def folder
folder_environments = project.environments.where(environment_type: params[:id])
@environments = folder_environments.with_state(params[:scope] || :available)
respond_to do |format|
format.html
format.json do
render json: {
environments: EnvironmentSerializer
.new(project: @project, user: @current_user)
.with_pagination(request, response)
.represent(@environments),
available_count: folder_environments.available.count,
stopped_count: folder_environments.stopped.count
}
end end
end end
end end
......
...@@ -248,6 +248,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController ...@@ -248,6 +248,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
respond_to do |format| respond_to do |format|
format.html do format.html do
define_new_vars define_new_vars
@show_changes_tab = true
render "new" render "new"
end end
format.json do format.json do
...@@ -395,10 +396,13 @@ class Projects::MergeRequestsController < Projects::ApplicationController ...@@ -395,10 +396,13 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end end
def merge_widget_refresh def merge_widget_refresh
if merge_request.in_progress_merge_commit_sha || merge_request.state == 'merged' if merge_request.merge_when_build_succeeds
@status = :success
elsif merge_request.merge_when_build_succeeds
@status = :merge_when_build_succeeds @status = :merge_when_build_succeeds
else
# Only MRs that can be merged end in this action
# MR can be already picked up for merge / merged already or can be waiting for worker to be picked up
# in last case it does not have any special status. Possible error is handled inside widget js function
@status = :success
end end
render 'merge' render 'merge'
...@@ -674,6 +678,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController ...@@ -674,6 +678,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController
@labels = LabelsFinder.new(current_user, project_id: @project.id).execute @labels = LabelsFinder.new(current_user, project_id: @project.id).execute
@show_changes_tab = params[:show_changes].present?
define_pipelines_vars define_pipelines_vars
end end
......
...@@ -12,7 +12,7 @@ class Projects::RunnersController < Projects::ApplicationController ...@@ -12,7 +12,7 @@ class Projects::RunnersController < Projects::ApplicationController
end end
def update def update
if @runner.update_attributes(runner_params) if Ci::UpdateRunnerService.new(@runner).update(runner_params)
redirect_to runner_path(@runner), notice: 'Runner was successfully updated.' redirect_to runner_path(@runner), notice: 'Runner was successfully updated.'
else else
render 'edit' render 'edit'
...@@ -28,7 +28,7 @@ class Projects::RunnersController < Projects::ApplicationController ...@@ -28,7 +28,7 @@ class Projects::RunnersController < Projects::ApplicationController
end end
def resume def resume
if @runner.update_attributes(active: true) if Ci::UpdateRunnerService.new(@runner).update(active: true)
redirect_to runner_path(@runner), notice: 'Runner was successfully updated.' redirect_to runner_path(@runner), notice: 'Runner was successfully updated.'
else else
redirect_to runner_path(@runner), alert: 'Runner was not updated.' redirect_to runner_path(@runner), alert: 'Runner was not updated.'
...@@ -36,7 +36,7 @@ class Projects::RunnersController < Projects::ApplicationController ...@@ -36,7 +36,7 @@ class Projects::RunnersController < Projects::ApplicationController
end end
def pause def pause
if @runner.update_attributes(active: false) if Ci::UpdateRunnerService.new(@runner).update(active: false)
redirect_to runner_path(@runner), notice: 'Runner was successfully updated.' redirect_to runner_path(@runner), notice: 'Runner was successfully updated.'
else else
redirect_to runner_path(@runner), alert: 'Runner was not updated.' redirect_to runner_path(@runner), alert: 'Runner was not updated.'
......
...@@ -27,7 +27,7 @@ class Projects::TagsController < Projects::ApplicationController ...@@ -27,7 +27,7 @@ class Projects::TagsController < Projects::ApplicationController
end end
def create def create
result = CreateTagService.new(@project, current_user). result = Tags::CreateService.new(@project, current_user).
execute(params[:tag_name], params[:ref], params[:message], params[:release_description]) execute(params[:tag_name], params[:ref], params[:message], params[:release_description])
if result[:status] == :success if result[:status] == :success
...@@ -41,7 +41,7 @@ class Projects::TagsController < Projects::ApplicationController ...@@ -41,7 +41,7 @@ class Projects::TagsController < Projects::ApplicationController
end end
def destroy def destroy
DeleteTagService.new(project, current_user).execute(params[:id]) Tags::DestroyService.new(project, current_user).execute(params[:id])
respond_to do |format| respond_to do |format|
format.html do format.html do
......
...@@ -300,4 +300,13 @@ module ApplicationHelper ...@@ -300,4 +300,13 @@ module ApplicationHelper
def page_class def page_class
"issue-boards-page" if current_controller?(:boards) "issue-boards-page" if current_controller?(:boards)
end end
# Returns active css class when condition returns true
# otherwise returns nil.
#
# Example:
# %li{ class: active_when(params[:filter] == '1') }
def active_when(condition)
'active' if condition
end
end end
module NavHelper module NavHelper
def page_sidebar_class
if pinned_nav?
"page-sidebar-expanded page-sidebar-pinned"
end
end
def page_gutter_class def page_gutter_class
if current_path?('merge_requests#show') || if current_path?('merge_requests#show') ||
current_path?('merge_requests#diffs') || current_path?('merge_requests#diffs') ||
...@@ -32,10 +26,6 @@ module NavHelper ...@@ -32,10 +26,6 @@ module NavHelper
class_name = '' class_name = ''
class_name << " with-horizontal-nav" if defined?(nav) && nav class_name << " with-horizontal-nav" if defined?(nav) && nav
if pinned_nav?
class_name << " header-sidebar-expanded header-sidebar-pinned"
end
class_name class_name
end end
...@@ -46,8 +36,4 @@ module NavHelper ...@@ -46,8 +36,4 @@ module NavHelper
def nav_control_class def nav_control_class
"nav-control" if current_user "nav-control" if current_user
end end
def pinned_nav?
cookies[:pin_nav] == 'true'
end
end end
...@@ -34,6 +34,10 @@ module PageLayoutHelper ...@@ -34,6 +34,10 @@ module PageLayoutHelper
end end
end end
def favicon
Rails.env.development? ? 'favicon-blue.ico' : 'favicon.ico'
end
def page_image def page_image
default = image_url('gitlab_logo.png') default = image_url('gitlab_logo.png')
......
...@@ -41,10 +41,6 @@ module PreferencesHelper ...@@ -41,10 +41,6 @@ module PreferencesHelper
] ]
end end
def user_application_theme
Gitlab::Themes.for_user(current_user).css_class
end
def user_color_scheme def user_color_scheme
Gitlab::ColorSchemes.for_user(current_user).css_class Gitlab::ColorSchemes.for_user(current_user).css_class
end end
......
...@@ -63,33 +63,10 @@ module Ci ...@@ -63,33 +63,10 @@ module Ci
new_build.save new_build.save
end end
def retry(build, user = nil) def retry(build, current_user)
new_build = Ci::Build.create( Ci::RetryBuildService
ref: build.ref, .new(build.project, current_user)
tag: build.tag, .execute(build)
options: build.options,
commands: build.commands,
tag_list: build.tag_list,
project: build.project,
pipeline: build.pipeline,
name: build.name,
allow_failure: build.allow_failure,
stage: build.stage,
stage_idx: build.stage_idx,
trigger_request: build.trigger_request,
yaml_variables: build.yaml_variables,
when: build.when,
user: user,
environment: build.environment,
status_event: 'enqueue'
)
MergeRequests::AddTodoWhenBuildFailsService
.new(build.project, nil)
.close(new_build)
build.pipeline.mark_as_processable_after_stage(build.stage_idx)
new_build
end end
end end
...@@ -137,7 +114,7 @@ module Ci ...@@ -137,7 +114,7 @@ module Ci
project.builds_enabled? && commands.present? && manual? && skipped? project.builds_enabled? && commands.present? && manual? && skipped?
end end
def play(current_user = nil) def play(current_user)
# Try to queue a current build # Try to queue a current build
if self.enqueue if self.enqueue
self.update(user: current_user) self.update(user: current_user)
......
...@@ -214,21 +214,17 @@ module Ci ...@@ -214,21 +214,17 @@ module Ci
def cancel_running def cancel_running
Gitlab::OptimisticLocking.retry_lock( Gitlab::OptimisticLocking.retry_lock(
statuses.cancelable) do |cancelable| statuses.cancelable) do |cancelable|
cancelable.each(&:cancel) cancelable.find_each(&:cancel)
end end
end end
def retry_failed(user) def retry_failed(current_user)
Gitlab::OptimisticLocking.retry_lock( Ci::RetryPipelineService.new(project, current_user)
builds.latest.failed_or_canceled) do |failed_or_canceled| .execute(self)
failed_or_canceled.select(&:retryable?).each do |build|
Ci::Build.retry(build, user)
end
end
end end
def mark_as_processable_after_stage(stage_idx) def mark_as_processable_after_stage(stage_idx)
builds.skipped.where('stage_idx > ?', stage_idx).find_each(&:process) builds.skipped.after_stage(stage_idx).find_each(&:process)
end end
def latest? def latest?
......
...@@ -22,8 +22,6 @@ module Ci ...@@ -22,8 +22,6 @@ module Ci
scope :online, ->() { where('contacted_at > ?', LAST_CONTACT_TIME) } scope :online, ->() { where('contacted_at > ?', LAST_CONTACT_TIME) }
scope :ordered, ->() { order(id: :desc) } scope :ordered, ->() { order(id: :desc) }
after_save :tick_runner_queue, if: :form_editable_changed?
scope :owned_or_shared, ->(project_id) do scope :owned_or_shared, ->(project_id) do
joins('LEFT JOIN ci_runner_projects ON ci_runner_projects.runner_id = ci_runners.id') joins('LEFT JOIN ci_runner_projects ON ci_runner_projects.runner_id = ci_runners.id')
.where("ci_runner_projects.gl_project_id = :project_id OR ci_runners.is_shared = true", project_id: project_id) .where("ci_runner_projects.gl_project_id = :project_id OR ci_runners.is_shared = true", project_id: project_id)
...@@ -40,6 +38,8 @@ module Ci ...@@ -40,6 +38,8 @@ module Ci
acts_as_taggable acts_as_taggable
after_destroy :cleanup_runner_queue
# Searches for runners matching the given query. # Searches for runners matching the given query.
# #
# This method uses ILIKE on PostgreSQL and LIKE on MySQL. # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
...@@ -147,14 +147,14 @@ module Ci ...@@ -147,14 +147,14 @@ module Ci
private private
def runner_queue_key def cleanup_runner_queue
"runner:build_queue:#{self.token}" Gitlab::Redis.with do |redis|
redis.del(runner_queue_key)
end
end end
def form_editable_changed? def runner_queue_key
FORM_EDITABLE.any? do |editable| "runner:build_queue:#{self.token}"
public_send("#{editable}_changed?")
end
end end
def tag_constraints def tag_constraints
......
...@@ -23,9 +23,6 @@ class CommitStatus < ActiveRecord::Base ...@@ -23,9 +23,6 @@ class CommitStatus < ActiveRecord::Base
where(id: max_id.group(:name, :commit_id)) where(id: max_id.group(:name, :commit_id))
end end
scope :retried, -> { where.not(id: latest) }
scope :ordered, -> { order(:name) }
scope :failed_but_allowed, -> do scope :failed_but_allowed, -> do
where(allow_failure: true, status: [:failed, :canceled]) where(allow_failure: true, status: [:failed, :canceled])
end end
...@@ -36,8 +33,11 @@ class CommitStatus < ActiveRecord::Base ...@@ -36,8 +33,11 @@ class CommitStatus < ActiveRecord::Base
false, all_state_names - [:failed, :canceled]) false, all_state_names - [:failed, :canceled])
end end
scope :retried, -> { where.not(id: latest) }
scope :ordered, -> { order(:name) }
scope :latest_ordered, -> { latest.ordered.includes(project: :namespace) } scope :latest_ordered, -> { latest.ordered.includes(project: :namespace) }
scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) } scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) }
scope :after_stage, -> (index) { where('stage_idx > ?', index) }
state_machine :status do state_machine :status do
event :enqueue do event :enqueue do
......
...@@ -43,7 +43,7 @@ class Namespace < ActiveRecord::Base ...@@ -43,7 +43,7 @@ class Namespace < ActiveRecord::Base
after_commit :refresh_access_of_projects_invited_groups, on: :update, if: -> { previous_changes.key?('share_with_group_lock') } after_commit :refresh_access_of_projects_invited_groups, on: :update, if: -> { previous_changes.key?('share_with_group_lock') }
# Save the storage paths before the projects are destroyed to use them on after destroy # Save the storage paths before the projects are destroyed to use them on after destroy
before_destroy(prepend: true) { @old_repository_storage_paths = repository_storage_paths } before_destroy(prepend: true) { prepare_for_destroy }
after_destroy :rm_dir after_destroy :rm_dir
scope :root, -> { where('type IS NULL') } scope :root, -> { where('type IS NULL') }
...@@ -216,6 +216,14 @@ class Namespace < ActiveRecord::Base ...@@ -216,6 +216,14 @@ class Namespace < ActiveRecord::Base
parent_id_changed? parent_id_changed?
end end
def prepare_for_destroy
old_repository_storage_paths
end
def old_repository_storage_paths
@old_repository_storage_paths ||= repository_storage_paths
end
private private
def repository_storage_paths def repository_storage_paths
...@@ -229,7 +237,7 @@ class Namespace < ActiveRecord::Base ...@@ -229,7 +237,7 @@ class Namespace < ActiveRecord::Base
def rm_dir def rm_dir
# Remove the namespace directory in all storages paths used by member projects # Remove the namespace directory in all storages paths used by member projects
@old_repository_storage_paths.each do |repository_storage_path| old_repository_storage_paths.each do |repository_storage_path|
# Move namespace directory into trash. # Move namespace directory into trash.
# We will remove it later async # We will remove it later async
new_path = "#{path}+#{id}+deleted" new_path = "#{path}+#{id}+deleted"
......
...@@ -234,6 +234,8 @@ class Project < ActiveRecord::Base ...@@ -234,6 +234,8 @@ class Project < ActiveRecord::Base
# Scopes # Scopes
default_scope { where(pending_delete: false) } default_scope { where(pending_delete: false) }
scope :with_deleted, -> { unscope(where: :pending_delete) }
scope :sorted_by_activity, -> { reorder(last_activity_at: :desc) } scope :sorted_by_activity, -> { reorder(last_activity_at: :desc) }
scope :sorted_by_stars, -> { reorder('projects.star_count DESC') } scope :sorted_by_stars, -> { reorder('projects.star_count DESC') }
......
...@@ -30,5 +30,9 @@ module ChatMessage ...@@ -30,5 +30,9 @@ module ChatMessage
def attachment_color def attachment_color
'#345' '#345'
end end
def link(text, url)
"[#{text}](#{url})"
end
end end
end end
...@@ -7,7 +7,11 @@ module ChatMessage ...@@ -7,7 +7,11 @@ module ChatMessage
attr_reader :project_name attr_reader :project_name
attr_reader :project_url attr_reader :project_url
attr_reader :user_name attr_reader :user_name
attr_reader :user_url
attr_reader :duration attr_reader :duration
attr_reader :stage
attr_reader :build_id
attr_reader :build_name
def initialize(params) def initialize(params)
@sha = params[:sha] @sha = params[:sha]
...@@ -17,7 +21,11 @@ module ChatMessage ...@@ -17,7 +21,11 @@ module ChatMessage
@project_url = params[:project_url] @project_url = params[:project_url]
@status = params[:commit][:status] @status = params[:commit][:status]
@user_name = params[:commit][:author_name] @user_name = params[:commit][:author_name]
@user_url = params[:commit][:author_url]
@duration = params[:commit][:duration] @duration = params[:commit][:duration]
@stage = params[:build_stage]
@build_name = params[:build_name]
@build_id = params[:build_id]
end end
def pretext def pretext
...@@ -35,7 +43,19 @@ module ChatMessage ...@@ -35,7 +43,19 @@ module ChatMessage
private private
def message def message
"#{project_link}: Commit #{commit_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{duration} #{'second'.pluralize(duration)}" "#{project_link}: Commit #{commit_link} of #{branch_link} #{ref_type} by #{user_link} #{humanized_status} on build #{build_link} of stage #{stage} in #{duration} #{'second'.pluralize(duration)}"
end
def build_url
"#{project_url}/builds/#{build_id}"
end
def build_link
link(build_name, build_url)
end
def user_link
link(user_name, user_url)
end end
def format(string) def format(string)
...@@ -64,11 +84,11 @@ module ChatMessage ...@@ -64,11 +84,11 @@ module ChatMessage
end end
def branch_link def branch_link
"[#{ref}](#{branch_url})" link(ref, branch_url)
end end
def project_link def project_link
"[#{project_name}](#{project_url})" link(project_name, project_url)
end end
def commit_url def commit_url
...@@ -76,7 +96,7 @@ module ChatMessage ...@@ -76,7 +96,7 @@ module ChatMessage
end end
def commit_link def commit_link
"[#{Commit.truncate_sha(sha)}](#{commit_url})" link(Commit.truncate_sha(sha), commit_url)
end end
end end
end end
...@@ -55,11 +55,11 @@ module ChatMessage ...@@ -55,11 +55,11 @@ module ChatMessage
end end
def project_link def project_link
"[#{project_name}](#{project_url})" link(project_name, project_url)
end end
def issue_link def issue_link
"[#{issue_title}](#{issue_url})" link(issue_title, issue_url)
end end
def issue_title def issue_title
......
...@@ -43,7 +43,7 @@ module ChatMessage ...@@ -43,7 +43,7 @@ module ChatMessage
end end
def project_link def project_link
"[#{project_name}](#{project_url})" link(project_name, project_url)
end end
def merge_request_message def merge_request_message
...@@ -51,7 +51,7 @@ module ChatMessage ...@@ -51,7 +51,7 @@ module ChatMessage
end end
def merge_request_link def merge_request_link
"[merge request !#{merge_request_id}](#{merge_request_url})" link("merge request !#{merge_request_id}", merge_request_url)
end end
def merge_request_url def merge_request_url
......
...@@ -3,10 +3,9 @@ module ChatMessage ...@@ -3,10 +3,9 @@ module ChatMessage
attr_reader :message attr_reader :message
attr_reader :user_name attr_reader :user_name
attr_reader :project_name attr_reader :project_name
attr_reader :project_link attr_reader :project_url
attr_reader :note attr_reader :note
attr_reader :note_url attr_reader :note_url
attr_reader :title
def initialize(params) def initialize(params)
params = HashWithIndifferentAccess.new(params) params = HashWithIndifferentAccess.new(params)
...@@ -69,15 +68,15 @@ module ChatMessage ...@@ -69,15 +68,15 @@ module ChatMessage
end end
def description_message def description_message
[{ text: format(@note), color: attachment_color }] [{ text: format(note), color: attachment_color }]
end end
def project_link def project_link
"[#{@project_name}](#{@project_url})" link(project_name, project_url)
end end
def commented_on_message(target, title) def commented_on_message(target, title)
@message = "#{@user_name} [commented on #{target}](#{@note_url}) in #{project_link}: *#{title}*" @message = "#{user_name} #{link('commented on ' + target, note_url)} in #{project_link}: *#{title}*"
end end
end end
end end
# TODO(ayufan): The GitLabCiService is deprecated and the type should be removed when the database entries are removed
class GitlabCiService < CiService
# We override the active accessor to always make GitLabCiService disabled
# Otherwise the GitLabCiService can be picked, but should never be since it's deprecated
def active
false
end
end
...@@ -173,6 +173,10 @@ class ProjectWiki ...@@ -173,6 +173,10 @@ class ProjectWiki
} }
end end
def repository_storage_path
project.repository_storage_path
end
private private
def init_repo(path_with_namespace) def init_repo(path_with_namespace)
......
...@@ -27,7 +27,7 @@ class Service < ActiveRecord::Base ...@@ -27,7 +27,7 @@ class Service < ActiveRecord::Base
validates :project_id, presence: true, unless: Proc.new { |service| service.template? } validates :project_id, presence: true, unless: Proc.new { |service| service.template? }
scope :visible, -> { where.not(type: ['GitlabIssueTrackerService', 'GitlabCiService']) } scope :visible, -> { where.not(type: 'GitlabIssueTrackerService') }
scope :issue_trackers, -> { where(category: 'issue_tracker') } scope :issue_trackers, -> { where(category: 'issue_tracker') }
scope :external_wikis, -> { where(type: 'ExternalWikiService').active } scope :external_wikis, -> { where(type: 'ExternalWikiService').active }
scope :active, -> { where(active: true) } scope :active, -> { where(active: true) }
......
...@@ -23,7 +23,6 @@ class User < ActiveRecord::Base ...@@ -23,7 +23,6 @@ class User < ActiveRecord::Base
default_value_for :can_create_team, false default_value_for :can_create_team, false
default_value_for :hide_no_ssh_key, false default_value_for :hide_no_ssh_key, false
default_value_for :hide_no_password, false default_value_for :hide_no_password, false
default_value_for :theme_id, gitlab_config.default_theme
attr_encrypted :otp_secret, attr_encrypted :otp_secret,
key: Gitlab::Application.secrets.otp_key_base, key: Gitlab::Application.secrets.otp_key_base,
......
...@@ -20,8 +20,6 @@ class EnvironmentSerializer < BaseSerializer ...@@ -20,8 +20,6 @@ class EnvironmentSerializer < BaseSerializer
end end
def represent(resource, opts = {}) def represent(resource, opts = {})
resource = @paginator.paginate(resource) if paginated?
if itemized? if itemized?
itemize(resource).map do |item| itemize(resource).map do |item|
{ name: item.name, { name: item.name,
...@@ -29,6 +27,8 @@ class EnvironmentSerializer < BaseSerializer ...@@ -29,6 +27,8 @@ class EnvironmentSerializer < BaseSerializer
latest: super(item.latest, opts) } latest: super(item.latest, opts) }
end end
else else
resource = @paginator.paginate(resource) if paginated?
super(resource, opts) super(resource, opts)
end end
end end
...@@ -36,15 +36,20 @@ class EnvironmentSerializer < BaseSerializer ...@@ -36,15 +36,20 @@ class EnvironmentSerializer < BaseSerializer
private private
def itemize(resource) def itemize(resource)
items = resource.group(:item_name).order('item_name ASC') items = resource.order('folder_name ASC')
.pluck('COALESCE(environment_type, name) AS item_name', .group('COALESCE(environment_type, name)')
'COUNT(*) AS environments_count', .select('COALESCE(environment_type, name) AS folder_name',
'MAX(id) AS last_environment_id') 'COUNT(*) AS size', 'MAX(id) AS last_id')
# It makes a difference when you call `paginate` method, because
# although `page` is effective at the end, it calls counting methods
# immediately.
items = @paginator.paginate(items) if paginated?
environments = resource.where(id: items.map(&:last)).index_by(&:id) environments = resource.where(id: items.map(&:last_id)).index_by(&:id)
items.map do |name, size, id| items.map do |item|
Item.new(name, size, environments[id]) Item.new(item.folder_name, item.size, environments[item.last_id])
end end
end end
end end
class BaseService class BaseService
include Gitlab::Allowable
include Gitlab::CurrentSettings include Gitlab::CurrentSettings
attr_accessor :project, :current_user, :params attr_accessor :project, :current_user, :params
...@@ -7,10 +8,6 @@ class BaseService ...@@ -7,10 +8,6 @@ class BaseService
@project, @current_user, @params = project, user, params.dup @project, @current_user, @params = project, user, params.dup
end end
def can?(object, action, subject)
Ability.allowed?(object, action, subject)
end
def notification_service def notification_service
NotificationService.new NotificationService.new
end end
......
module Ci
class RetryBuildService < ::BaseService
CLONE_ATTRIBUTES = %i[pipeline ref tag options commands tag_list name
allow_failure stage stage_idx trigger_request
yaml_variables when environment coverage_regex]
.freeze
REJECT_ATTRIBUTES = %i[id status user token coverage trace runner
artifacts_file artifacts_metadata artifacts_size
created_at updated_at started_at finished_at
queued_at erased_by erased_at].freeze
IGNORE_ATTRIBUTES = %i[trace type lock_version project target_url
deploy job_id description].freeze
def execute(build)
reprocess(build).tap do |new_build|
build.pipeline.mark_as_processable_after_stage(build.stage_idx)
new_build.enqueue!
MergeRequests::AddTodoWhenBuildFailsService
.new(project, current_user)
.close(new_build)
end
end
def reprocess(build)
unless can?(current_user, :update_build, build)
raise Gitlab::Access::AccessDeniedError
end
attributes = CLONE_ATTRIBUTES.map do |attribute|
[attribute, build.send(attribute)]
end
attributes.push([:user, current_user])
project.builds.create(Hash[attributes])
end
end
end
module Ci
class RetryPipelineService < ::BaseService
def execute(pipeline)
unless can?(current_user, :update_pipeline, pipeline)
raise Gitlab::Access::AccessDeniedError
end
pipeline.builds.failed_or_canceled.find_each do |build|
next unless build.retryable?
Ci::RetryBuildService.new(project, current_user)
.reprocess(build)
end
MergeRequests::AddTodoWhenBuildFailsService
.new(project, current_user)
.close_all(pipeline)
pipeline.process!
end
end
end
module Ci
class UpdateRunnerService
attr_reader :runner
def initialize(runner)
@runner = runner
end
def update(params)
runner.update(params).tap do |updated|
runner.tick_runner_queue if updated
end
end
end
end
class CreateTagService < BaseService
def execute(tag_name, target, message, release_description = nil)
valid_tag = Gitlab::GitRefValidator.validate(tag_name)
return error('Tag name invalid') unless valid_tag
repository = project.repository
message&.strip!
new_tag = nil
begin
new_tag = repository.add_tag(current_user, tag_name, target, message)
rescue Rugged::TagError
return error("Tag #{tag_name} already exists")
rescue GitHooksService::PreReceiveError => ex
return error(ex.message)
end
if new_tag
if release_description
CreateReleaseService.new(@project, @current_user).
execute(tag_name, release_description)
end
success.merge(tag: new_tag)
else
error("Target #{target} is invalid")
end
end
end
class DeleteTagService < BaseService
def execute(tag_name)
repository = project.repository
tag = repository.find_tag(tag_name)
unless tag
return error('No such tag', 404)
end
if repository.rm_tag(current_user, tag_name)
release = project.releases.find_by(tag: tag_name)
release&.destroy
push_data = build_push_data(tag)
EventCreateService.new.push(project, current_user, push_data)
project.execute_hooks(push_data.dup, :tag_push_hooks)
project.execute_services(push_data.dup, :tag_push_hooks)
success('Tag was removed')
else
error('Failed to remove tag')
end
end
def error(message, return_code = 400)
super(message).merge(return_code: return_code)
end
def success(message)
super().merge(message: message)
end
def build_push_data(tag)
Gitlab::DataBuilder::Push.build(
project,
current_user,
tag.dereferenced_target.sha,
Gitlab::Git::BLANK_SHA,
"#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}",
[])
end
end
...@@ -8,7 +8,9 @@ module Groups ...@@ -8,7 +8,9 @@ module Groups
end end
def execute def execute
group.projects.each do |project| group.prepare_for_destroy
group.projects.with_deleted.each do |project|
# Execute the destruction of the models immediately to ensure atomic cleanup. # Execute the destruction of the models immediately to ensure atomic cleanup.
# Skip repository removal because we remove directory with namespace # Skip repository removal because we remove directory with namespace
# that contain all these repositories # that contain all these repositories
......
...@@ -18,5 +18,11 @@ module MergeRequests ...@@ -18,5 +18,11 @@ module MergeRequests
todo_service.merge_request_build_retried(merge_request) todo_service.merge_request_build_retried(merge_request)
end end
end end
def close_all(pipeline)
pipeline_merge_requests(pipeline) do |merge_request|
todo_service.merge_request_build_retried(merge_request)
end
end
end end
end end
module Tags
class CreateService < BaseService
def execute(tag_name, target, message, release_description = nil)
valid_tag = Gitlab::GitRefValidator.validate(tag_name)
return error('Tag name invalid') unless valid_tag
repository = project.repository
message&.strip!
new_tag = nil
begin
new_tag = repository.add_tag(current_user, tag_name, target, message)
rescue Rugged::TagError
return error("Tag #{tag_name} already exists")
rescue GitHooksService::PreReceiveError => ex
return error(ex.message)
end
if new_tag
if release_description
CreateReleaseService.new(@project, @current_user).
execute(tag_name, release_description)
end
success.merge(tag: new_tag)
else
error("Target #{target} is invalid")
end
end
end
end
module Tags
class DestroyService < BaseService
def execute(tag_name)
repository = project.repository
tag = repository.find_tag(tag_name)
unless tag
return error('No such tag', 404)
end
if repository.rm_tag(current_user, tag_name)
release = project.releases.find_by(tag: tag_name)
release&.destroy
push_data = build_push_data(tag)
EventCreateService.new.push(project, current_user, push_data)
project.execute_hooks(push_data.dup, :tag_push_hooks)
project.execute_services(push_data.dup, :tag_push_hooks)
success('Tag was removed')
else
error('Failed to remove tag')
end
end
def error(message, return_code = 400)
super(message).merge(return_code: return_code)
end
def success(message)
super().merge(message: message)
end
def build_push_data(tag)
Gitlab::DataBuilder::Push.build(
project,
current_user,
tag.dereferenced_target.sha,
Gitlab::Git::BLANK_SHA,
"#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}",
[])
end
end
end
...@@ -36,7 +36,7 @@ class FileUploader < GitlabUploader ...@@ -36,7 +36,7 @@ class FileUploader < GitlabUploader
escaped_filename = filename.gsub("]", "\\]") escaped_filename = filename.gsub("]", "\\]")
markdown = "[#{escaped_filename}](#{self.secure_url})" markdown = "[#{escaped_filename}](#{self.secure_url})"
markdown.prepend("!") if image_or_video? markdown.prepend("!") if image_or_video? || dangerous?
{ {
alt: filename, alt: filename,
......
# Extra methods for uploader # Extra methods for uploader
module UploaderHelper module UploaderHelper
IMAGE_EXT = %w[png jpg jpeg gif bmp tiff svg] IMAGE_EXT = %w[png jpg jpeg gif bmp tiff]
# We recommend using the .mp4 format over .mov. Videos in .mov format can # We recommend using the .mp4 format over .mov. Videos in .mov format can
# still be used but you really need to make sure they are served with the # still be used but you really need to make sure they are served with the
# proper MIME type video/mp4 and not video/quicktime or your videos won't play # proper MIME type video/mp4 and not video/quicktime or your videos won't play
# on IE >= 9. # on IE >= 9.
# http://archive.sublimevideo.info/20150912/docs.sublimevideo.net/troubleshooting.html # http://archive.sublimevideo.info/20150912/docs.sublimevideo.net/troubleshooting.html
VIDEO_EXT = %w[mp4 m4v mov webm ogv] VIDEO_EXT = %w[mp4 m4v mov webm ogv]
# These extension types can contain dangerous code and should only be embedded inline with
# proper filtering. They should always be tagged as "Content-Disposition: attachment", not "inline".
DANGEROUS_EXT = %w[svg]
def image? def image?
extension_match?(IMAGE_EXT) extension_match?(IMAGE_EXT)
...@@ -20,6 +23,10 @@ module UploaderHelper ...@@ -20,6 +23,10 @@ module UploaderHelper
image? || video? image? || video?
end end
def dangerous?
extension_match?(DANGEROUS_EXT)
end
def extension_match?(extensions) def extension_match?(extensions)
return false unless file return false unless file
......
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
%th.wide Message %th.wide Message
%th Action %th Action
= render @abuse_reports = render @abuse_reports
= paginate @abuse_reports, theme: 'gitlab'
- else - else
.empty-state .empty-state
.text-center .text-center
......
...@@ -209,7 +209,7 @@ ...@@ -209,7 +209,7 @@
= f.label :max_pages_size, 'Maximum size of pages (MB)', class: 'control-label col-sm-2' = f.label :max_pages_size, 'Maximum size of pages (MB)', class: 'control-label col-sm-2'
.col-sm-10 .col-sm-10
= f.number_field :max_pages_size, class: 'form-control' = f.number_field :max_pages_size, class: 'form-control'
.help-block Zero for unlimited .help-block 0 for unlimited
%fieldset %fieldset
%legend Continuous Integration %legend Continuous Integration
...@@ -585,7 +585,7 @@ ...@@ -585,7 +585,7 @@
= f.number_field :terminal_max_session_time, class: 'form-control' = f.number_field :terminal_max_session_time, class: 'form-control'
.help-block .help-block
Maximum time for web terminal websocket connection (in seconds). Maximum time for web terminal websocket connection (in seconds).
Set to 0 for unlimited time. 0 for unlimited.
.form-actions .form-actions
= f.submit 'Save', class: 'btn btn-save' = f.submit 'Save', class: 'btn btn-save'
...@@ -8,15 +8,14 @@ ...@@ -8,15 +8,14 @@
%div{ class: container_class } %div{ class: container_class }
%ul.nav-links.log-tabs %ul.nav-links.log-tabs
- loggers.each do |klass| - loggers.each do |klass|
%li{ class: (klass == Gitlab::GitLogger ? 'active' : '') }> %li{ class: active_when(klass == Gitlab::GitLogger) }>
= link_to klass::file_name, "##{klass::file_name_noext}", = link_to klass::file_name, "##{klass::file_name_noext}",
'data-toggle' => 'tab' 'data-toggle' => 'tab'
.row-content-block .row-content-block
To prevent performance issues admin logs output the last 2000 lines To prevent performance issues admin logs output the last 2000 lines
.tab-content .tab-content
- loggers.each do |klass| - loggers.each do |klass|
.tab-pane{ class: (klass == Gitlab::GitLogger ? 'active' : ''), .tab-pane{ class: active_when(klass == Gitlab::GitLogger), id: klass::file_name_noext }
id: klass::file_name_noext }
.file-holder#README .file-holder#README
.js-file-title.file-title .js-file-title.file-title
%i.fa.fa-file %i.fa.fa-file
......
...@@ -48,13 +48,13 @@ ...@@ -48,13 +48,13 @@
= link_to admin_projects_path do = link_to admin_projects_path do
All All
= nav_link(html_options: { class: params[:visibility_level] == Gitlab::VisibilityLevel::PRIVATE.to_s ? 'active' : '' }) do = nav_link(html_options: { class: active_when(params[:visibility_level] == Gitlab::VisibilityLevel::PRIVATE.to_s) }) do
= link_to admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PRIVATE) do = link_to admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PRIVATE) do
Private Private
= nav_link(html_options: { class: params[:visibility_level] == Gitlab::VisibilityLevel::INTERNAL.to_s ? 'active' : '' }) do = nav_link(html_options: { class: active_when(params[:visibility_level] == Gitlab::VisibilityLevel::INTERNAL.to_s) }) do
= link_to admin_projects_path(visibility_level: Gitlab::VisibilityLevel::INTERNAL) do = link_to admin_projects_path(visibility_level: Gitlab::VisibilityLevel::INTERNAL) do
Internal Internal
= nav_link(html_options: { class: params[:visibility_level] == Gitlab::VisibilityLevel::PUBLIC.to_s ? 'active' : '' }) do = nav_link(html_options: { class: active_when(params[:visibility_level] == Gitlab::VisibilityLevel::PUBLIC.to_s) }) do
= link_to admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PUBLIC) do = link_to admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PUBLIC) do
Public Public
......
...@@ -22,7 +22,7 @@ ...@@ -22,7 +22,7 @@
%td %td
#{runner.builds.count(:all)} #{runner.builds.count(:all)}
%td %td
- runner.tag_list.each do |tag| - runner.tag_list.sort.each do |tag|
%span.label.label-primary %span.label.label-primary
= tag = tag
%td %td
......
...@@ -39,31 +39,31 @@ ...@@ -39,31 +39,31 @@
.nav-block .nav-block
%ul.nav-links.wide.scrolling-tabs.white.scrolling-tabs %ul.nav-links.wide.scrolling-tabs.white.scrolling-tabs
.fade-left .fade-left
= nav_link(html_options: { class: ('active' unless params[:filter]) }) do = nav_link(html_options: { class: active_when(params[:filter].nil?) }) do
= link_to admin_users_path do = link_to admin_users_path do
Active Active
%small.badge= number_with_delimiter(User.active.count) %small.badge= number_with_delimiter(User.active.count)
= nav_link(html_options: { class: ('active' if params[:filter] == 'admins') }) do = nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do
= link_to admin_users_path(filter: "admins") do = link_to admin_users_path(filter: "admins") do
Admins Admins
%small.badge= number_with_delimiter(User.admins.count) %small.badge= number_with_delimiter(User.admins.count)
= nav_link(html_options: { class: "#{'active' if params[:filter] == 'two_factor_enabled'} filter-two-factor-enabled" }) do = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do
= link_to admin_users_path(filter: 'two_factor_enabled') do = link_to admin_users_path(filter: 'two_factor_enabled') do
2FA Enabled 2FA Enabled
%small.badge= number_with_delimiter(User.with_two_factor.count) %small.badge= number_with_delimiter(User.with_two_factor.count)
= nav_link(html_options: { class: "#{'active' if params[:filter] == 'two_factor_disabled'} filter-two-factor-disabled" }) do = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do
= link_to admin_users_path(filter: 'two_factor_disabled') do = link_to admin_users_path(filter: 'two_factor_disabled') do
2FA Disabled 2FA Disabled
%small.badge= number_with_delimiter(User.without_two_factor.count) %small.badge= number_with_delimiter(User.without_two_factor.count)
= nav_link(html_options: { class: ('active' if params[:filter] == 'external') }) do = nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do
= link_to admin_users_path(filter: 'external') do = link_to admin_users_path(filter: 'external') do
External External
%small.badge= number_with_delimiter(User.external.count) %small.badge= number_with_delimiter(User.external.count)
= nav_link(html_options: { class: ('active' if params[:filter] == 'blocked') }) do = nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do
= link_to admin_users_path(filter: "blocked") do = link_to admin_users_path(filter: "blocked") do
Blocked Blocked
%small.badge= number_with_delimiter(User.blocked.count) %small.badge= number_with_delimiter(User.blocked.count)
= nav_link(html_options: { class: ('active' if params[:filter] == 'wop') }) do = nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do
= link_to admin_users_path(filter: "wop") do = link_to admin_users_path(filter: "wop") do
Without projects Without projects
%small.badge= number_with_delimiter(User.without_projects.count) %small.badge= number_with_delimiter(User.without_projects.count)
......
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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