Commit 867b9f93 authored by Rémy Coutable's avatar Rémy Coutable

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

Signed-off-by: default avatarRémy Coutable <remy@rymai.me>
parents 90910331 74f211f0
### 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].
### Retrospective
After each release (usually on the 22nd of each month), we have a retrospective
call where we discuss what went well, what went wrong, and what we can improve
for the next release. The [retrospective notes] are public and you are invited
to comment them.
If you're interested, you can even join the [retrospective call][retro-kickoff-call].
After each release, we have a retrospective call where we discuss what went well,
what went wrong, and what we can improve for the next release. The
[retrospective notes] are public and you are invited to comment on them.
If you're interested, you can even join the
[retrospective call][retro-kickoff-call], on the first working day after the
22nd at 6pm CET / 9am PST.
### 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 notes] are public and you are invited to comment them.
If you're interested, you can even join the [kickoff call][retro-kickoff-call].
[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],
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
[kickoff notes]: https://docs.google.com/document/d/1ElPkZ90A8ey_iOkTvUs_ByMlwKK6NAB2VOK5835wYK0/edit?usp=sharing
......
......@@ -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 () {
return $(this).select().one('mouseup', function (e) {
return e.preventDefault();
......@@ -250,8 +245,6 @@ require('es6-promise').polyfill();
});
gl.awardsHandler = new AwardsHandler();
new Aside();
// bind sidebar events
new gl.Sidebar();
gl.utils.initTimeagoTimeout();
});
......
......@@ -97,7 +97,7 @@ $(() => {
},
computed: {
disabled() {
return Store.shouldAddBlankState();
return !this.store.lists.filter(list => list.type !== 'blank' && list.type !== 'done').length;
},
},
template: `
......
......@@ -4,6 +4,7 @@
*
* Used to store the Pipelines rendered in the commit view in the pipelines table.
*/
require('../../vue_realtime_listener');
class PipelinesStore {
constructor() {
......@@ -24,7 +25,7 @@ class PipelinesStore {
* update the time to show how long as passed.
*
*/
startTimeAgoLoops() {
static startTimeAgoLoops() {
const startTimeLoops = () => {
this.timeLoopInterval = setInterval(() => {
this.$children[0].$children.reduce((acc, component) => {
......@@ -44,7 +45,4 @@ class PipelinesStore {
}
}
window.gl = window.gl || {};
gl.commits = gl.commits || {};
gl.commits.pipelines = gl.commits.pipelines || {};
gl.commits.pipelines.PipelinesStore = PipelinesStore;
module.exports = PipelinesStore;
......@@ -6,9 +6,8 @@ window.Vue.use(require('vue-resource'));
require('../../lib/utils/common_utils');
require('../../vue_shared/vue_resource_interceptor');
require('../../vue_shared/components/pipelines_table');
require('../../vue_realtime_listener/index');
require('./pipelines_service');
require('./pipelines_store');
const PipelineStore = require('./pipelines_store');
/**
*
......@@ -41,7 +40,7 @@ require('./pipelines_store');
data() {
const pipelinesTableData = document.querySelector('#commit-pipeline-table-view').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.
const svgsObject = gl.utils.DOMStringMapToObject(svgsData);
......@@ -71,7 +70,6 @@ require('./pipelines_store');
.then(response => response.json())
.then((json) => {
this.store.storePipelines(json);
this.store.startTimeAgoLoops.call(this, Vue);
this.isLoading = false;
})
.catch(() => {
......@@ -80,9 +78,15 @@ require('./pipelines_store');
});
},
beforeUpdate() {
if (this.state.pipelines.length && this.$children) {
PipelineStore.startTimeAgoLoops.call(this, Vue);
}
},
template: `
<div>
<div class="pipelines realtime-loading" v-if="isLoading">
<div class="pipelines">
<div class="realtime-loading" v-if="isLoading">
<i class="fa fa-spinner fa-spin"></i>
</div>
......
......@@ -121,6 +121,7 @@ const ShortcutsBlob = require('./shortcuts_blob');
new gl.IssuableTemplateSelectors();
break;
case 'projects:merge_requests:new':
case 'projects:merge_requests:new_diffs':
case 'projects:merge_requests:edit':
new gl.Diff();
shortcut_handler = new ShortcutsNavigation();
......
......@@ -48,7 +48,7 @@
const calendar = new Pikaday({
field: $dueDateInput.get(0),
theme: 'gitlab-theme',
format: 'YYYY-MM-DD',
format: 'yyyy-mm-dd',
onSelect: (dateText) => {
const formattedDate = dateFormat(new Date(dateText), 'yyyy-mm-dd');
......@@ -63,6 +63,7 @@
}
});
calendar.setDate(new Date($dueDateInput.val()));
this.$datePicker.append(calendar.el);
this.$datePicker.data('pikaday', calendar);
}
......@@ -169,11 +170,12 @@
const calendar = new Pikaday({
field: $datePicker.get(0),
theme: 'gitlab-theme',
format: 'YYYY-MM-DD',
format: 'yyyy-mm-dd',
onSelect(dateText) {
$datePicker.val(dateFormat(new Date(dateText), 'yyyy-mm-dd'));
}
});
calendar.setDate(new Date($datePicker.val()));
$datePicker.data('pikaday', calendar);
});
......
/* eslint-disable no-param-reassign, no-new */
/* global Vue */
/* global EnvironmentsService */
/* global Flash */
window.Vue = require('vue');
window.Vue.use(require('vue-resource'));
require('../services/environments_service');
require('./environment_item');
const Vue = require('vue');
Vue.use(require('vue-resource'));
const EnvironmentsService = require('../services/environments_service');
const EnvironmentTable = require('./environments_table');
const EnvironmentsStore = require('../stores/environments_store');
require('../../vue_shared/components/table_pagination');
require('../../lib/utils/common_utils');
(() => {
window.gl = window.gl || {};
gl.environmentsList.EnvironmentsComponent = Vue.component('environment-component', {
props: {
store: {
type: Object,
required: true,
default: () => ({}),
},
},
module.exports = Vue.component('environment-component', {
components: {
'environment-item': gl.environmentsList.EnvironmentItem,
'environment-table': EnvironmentTable,
'table-pagination': gl.VueGlPagination,
},
data() {
const environmentsData = document.querySelector('#environments-list-view').dataset;
const store = new EnvironmentsStore();
return {
state: this.store.state,
store,
state: store.state,
visibility: 'available',
isLoading: false,
cssContainerClass: environmentsData.cssClass,
......@@ -43,25 +37,30 @@ require('./environment_item');
commitIconSvg: environmentsData.commitIconSvg,
playIconSvg: environmentsData.playIconSvg,
terminalIconSvg: environmentsData.terminalIconSvg,
// Pagination Properties,
paginationInformation: {},
pageNumber: 1,
};
},
computed: {
scope() {
return this.$options.getQueryParameter('scope');
return gl.utils.getParameterByName('scope');
},
canReadEnvironmentParsed() {
return this.$options.convertPermissionToBoolean(this.canReadEnvironment);
return gl.utils.convertPermissionToBoolean(this.canReadEnvironment);
},
canCreateDeploymentParsed() {
return this.$options.convertPermissionToBoolean(this.canCreateDeployment);
return gl.utils.convertPermissionToBoolean(this.canCreateDeployment);
},
canCreateEnvironmentParsed() {
return this.$options.convertPermissionToBoolean(this.canCreateEnvironment);
return gl.utils.convertPermissionToBoolean(this.canCreateEnvironment);
},
},
/**
......@@ -69,19 +68,27 @@ require('./environment_item');
* Toggles loading property.
*/
created() {
gl.environmentsService = new EnvironmentsService(this.endpoint);
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 scope = this.$options.getQueryParameter('scope');
if (scope) {
this.store.storeVisibility(scope);
}
const service = new EnvironmentsService(endpoint);
this.isLoading = true;
return gl.environmentsService.all()
.then(resp => resp.json())
.then((json) => {
this.store.storeEnvironments(json);
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(() => {
......@@ -90,33 +97,22 @@ require('./environment_item');
});
},
/**
* Transforms the url parameter into an object and
* returns the one requested.
*
* @param {String} param
* @returns {String} The value of the requested parameter.
*/
getQueryParameter(parameter) {
return window.location.search.substring(1).split('&').reduce((acc, param) => {
const paramSplited = param.split('=');
acc[paramSplited[0]] = paramSplited[1];
return acc;
}, {})[parameter];
methods: {
toggleRow(model) {
return this.store.toggleFolder(model.name);
},
/**
* Converts permission provided as strings to booleans.
* @param {String} string
* @returns {Boolean}
* Will change the page number and update the URL.
*
* @param {Number} pageNumber desired page to go to.
* @return {String}
*/
convertPermissionToBoolean(string) {
return string === 'true';
},
changePage(pageNumber) {
const param = gl.utils.setParamInURL('page', pageNumber);
methods: {
toggleRow(model) {
return this.store.toggleFolder(model.name);
gl.utils.visitUrl(param);
return param;
},
},
......@@ -124,14 +120,15 @@ require('./environment_item');
<div :class="cssContainerClass">
<div class="top-area">
<ul v-if="!isLoading" class="nav-links">
<li v-bind:class="{ 'active': scope === undefined }">
<li v-bind:class="{ 'active': scope === null || scope === 'available' }">
<a :href="projectEnvironmentsPath">
Available
<span class="badge js-available-environments-count">
{{state.availableCounter}}
</span>
</a>
</li><li v-bind:class="{ 'active' : scope === 'stopped' }">
</li>
<li v-bind:class="{ 'active' : scope === 'stopped' }">
<a :href="projectStoppedEnvironmentsPath">
Stopped
<span class="badge js-stopped-environments-count">
......@@ -165,8 +162,7 @@ require('./environment_item');
</a>
</p>
<a
v-if="canCreateEnvironmentParsed"
<a v-if="canCreateEnvironmentParsed"
:href="newEnvironmentPath"
class="btn btn-create js-new-environment-button">
New Environment
......@@ -174,50 +170,23 @@ require('./environment_item');
</div>
<div class="table-holder"
v-if="!isLoading && state.filteredEnvironments.length > 0">
<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 state.filteredEnvironments"
v-bind:model="model">
<tr
is="environment-item"
:model="model"
:toggleRow="toggleRow.bind(model)"
:can-create-deployment="canCreateDeploymentParsed"
:can-read-environment="canReadEnvironmentParsed"
:play-icon-svg="playIconSvg"
:terminal-icon-svg="terminalIconSvg"
:commit-icon-svg="commitIconSvg"></tr>
v-if="!isLoading && state.environments.length > 0">
<tr v-if="model.isOpen && model.children && model.children.length > 0"
is="environment-item"
v-for="children in model.children"
:model="children"
:toggleRow="toggleRow.bind(children)"
<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">
</tr>
</environment-table>
</template>
</tbody>
</table>
<table-pagination v-if="state.paginationInformation && state.paginationInformation.totalPages > 1"
:change="changePage"
:pageInfo="state.paginationInformation">
</table-pagination>
</div>
</div>
</div>
`,
});
})();
});
/* global Vue */
const Vue = require('vue');
window.Vue = require('vue');
(() => {
window.gl = window.gl || {};
window.gl.environmentsList = window.gl.environmentsList || {};
gl.environmentsList.ActionsComponent = Vue.component('actions-component', {
module.exports = Vue.component('actions-component', {
props: {
actions: {
type: Array,
......@@ -46,5 +40,4 @@ window.Vue = require('vue');
</div>
</div>
`,
});
})();
});
/* global Vue */
/**
* Renders the external url link in environments table.
*/
const Vue = require('vue');
window.Vue = require('vue');
(() => {
window.gl = window.gl || {};
window.gl.environmentsList = window.gl.environmentsList || {};
gl.environmentsList.ExternalUrlComponent = Vue.component('external-url-component', {
module.exports = Vue.component('external-url-component', {
props: {
externalUrl: {
type: String,
......@@ -19,5 +16,4 @@ window.Vue = require('vue');
<i class="fa fa-external-link"></i>
</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');
(() => {
window.gl = window.gl || {};
window.gl.environmentsList = window.gl.environmentsList || {};
gl.environmentsList.RollbackComponent = Vue.component('rollback-component', {
module.exports = Vue.component('rollback-component', {
props: {
retryUrl: {
type: String,
......@@ -29,5 +27,4 @@ window.Vue = require('vue');
</span>
</a>
`,
});
})();
});
/* global Vue */
/**
* Renders the stop "button" that allows stop an environment.
* Used in environments table.
*/
const Vue = require('vue');
window.Vue = require('vue');
(() => {
window.gl = window.gl || {};
window.gl.environmentsList = window.gl.environmentsList || {};
gl.environmentsList.StopComponent = Vue.component('stop-component', {
module.exports = Vue.component('stop-component', {
props: {
stopUrl: {
type: String,
......@@ -20,8 +18,7 @@ window.Vue = require('vue');
data-confirm="Are you sure you want to stop this environment?"
data-method="post"
rel="nofollow">
<i class="fa fa-stop stop-env-icon"></i>
<i class="fa fa-stop stop-env-icon" aria-hidden="true"></i>
</a>
`,
});
})();
});
/* global Vue */
/**
* Renders a terminal button to open a web terminal.
* Used in environments table.
*/
const Vue = require('vue');
window.Vue = require('vue');
(() => {
window.gl = window.gl || {};
window.gl.environmentsList = window.gl.environmentsList || {};
gl.environmentsList.TerminalButtonComponent = Vue.component('terminal-button-component', {
module.exports = Vue.component('terminal-button-component', {
props: {
terminalPath: {
type: String,
......@@ -24,5 +22,4 @@ window.Vue = require('vue');
<span class="js-terminal-icon-container" v-html="terminalIconSvg"></span>
</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');
require('./stores/environments_store');
require('./components/environment');
const EnvironmentsComponent = require('./components/environment');
require('../vue_shared/vue_resource_interceptor');
$(() => {
......@@ -9,14 +7,8 @@ $(() => {
if (gl.EnvironmentsListApp) {
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'),
propsData: {
store: Store.create(),
},
});
});
const EnvironmentsFolderComponent = require('./environments_folder_view');
require('../../vue_shared/vue_resource_interceptor');
$(() => {
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 = require('vue');
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');
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 */
/* eslint-disable no-unused-vars, no-param-reassign */
const Vue = require('vue');
class EnvironmentsService {
constructor(root) {
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();
});
constructor(endpoint) {
this.environments = Vue.resource(endpoint);
}
all() {
......@@ -22,4 +10,4 @@ class EnvironmentsService {
}
}
window.EnvironmentsService = EnvironmentsService;
module.exports = EnvironmentsService;
/* eslint-disable no-param-reassign */
(() => {
window.gl = window.gl || {};
window.gl.environmentsList = window.gl.environmentsList || {};
gl.environmentsList.EnvironmentsStore = {
state: {},
create() {
require('~/lib/utils/common_utils');
/**
* Environments Store.
*
* Stores received environments, count of stopped environments and count of
* available environments.
*/
class EnvironmentsStore {
constructor() {
this.state = {};
this.state.environments = [];
this.state.stoppedCounter = 0;
this.state.availableCounter = 0;
this.state.visibility = 'available';
this.state.filteredEnvironments = [];
this.state.paginationInformation = {};
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`
* sorted alphabetically.
* In each children a `vue-` property will be added. This property will be
* used to know if an item is a children mostly for css purposes. This is
* needed because the children row is a fragment instance and therfore does
* not accept non-prop attributes.
*
* Stores the received environments.
*
* @example
* it will transform this:
* [
* { name: "environment", environment_type: "review" },
* { name: "environment_1", environment_type: null }
* { name: "environment_2, environment_type: "review" }
* ]
* into this:
* [
* { name: "review", children:
* [
* { name: "environment", environment_type: "review", vue-isChildren: true},
* { name: "environment_2", environment_type: "review", vue-isChildren: true}
* ]
* },
* {name: "environment_1", environment_type: null}
* ]
* In the main environments endpoint, each environment has the following schema
* { name: String, size: Number, latest: Object }
* In the endpoint to retrieve environments from each folder, the environment does
* 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.
*
* 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.
*
* @param {Array} environments List of environments.
* @returns {Array} Tree structured array with the received environments.
* @param {Array} environments
* @returns {Array}
*/
storeEnvironments(environments = []) {
this.state.stoppedCounter = this.countByState(environments, 'stopped');
this.state.availableCounter = this.countByState(environments, 'available');
const environmentsTree = environments.reduce((acc, environment) => {
if (environment.environment_type !== null) {
const occurs = acc.filter(element => element.children &&
element.name === environment.environment_type);
environment['vue-isChildren'] = true;
const filteredEnvironments = environments.map((env) => {
let filtered = {};
if (occurs.length) {
acc[acc.indexOf(occurs[0])].children.push(environment);
acc[acc.indexOf(occurs[0])].children.slice().sort(this.sortByName);
} else {
acc.push({
name: environment.environment_type,
children: [environment],
isOpen: false,
'vue-isChildren': environment['vue-isChildren'],
});
if (env.size > 1) {
filtered = Object.assign({}, env, { isFolder: true, folderName: env.name });
}
if (env.latest) {
filtered = Object.assign(filtered, env, env.latest);
delete filtered.latest;
} else {
acc.push(environment);
filtered = Object.assign(filtered, env);
}
return acc;
}, []).slice().sort(this.sortByName);
this.state.environments = environmentsTree;
this.filterEnvironmentsByVisibility(this.state.environments);
return environmentsTree;
},
storeVisibility(visibility) {
this.state.visibility = visibility;
},
/**
* 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 filtered;
});
return this.filterState(this.state.visibility, item);
}).filter(Boolean);
this.state.environments = filteredEnvironments;
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;
setPagination(pagination = {}) {
const normalizedHeaders = gl.utils.normalizeHeaders(pagination);
const paginationInformation = gl.utils.parseIntPagination(normalizedHeaders);
return environmentsCopy;
},
this.state.paginationInformation = paginationInformation;
return paginationInformation;
}
/**
* Given an array of environments, returns the number of environments
* that have the given state.
* Stores the number of available environments.
*
* @param {Array} environments
* @param {String} state
* @returns {Number}
* @param {Number} count = 0
* @return {Number}
*/
countByState(environments, state) {
return environments.filter(env => env.state === state).length;
},
storeAvailableCount(count = 0) {
this.state.availableCounter = count;
return count;
}
/**
* Sorts the two objects provided by their name.
* Stores the number of closed environments.
*
* @param {Object} a
* @param {Object} b
* @returns {Number}
* @param {Number} count = 0
* @return {Number}
*/
sortByName(a, b) {
const nameA = a.name.toUpperCase();
const nameB = b.name.toUpperCase();
storeStoppedCount(count = 0) {
this.state.stoppedCounter = count;
return count;
}
}
return nameA < nameB ? -1 : nameA > nameB ? 1 : 0; // eslint-disable-line
},
};
})();
module.exports = EnvironmentsStore;
......@@ -103,6 +103,9 @@
this.input.each((i, input) => {
const $input = $(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) {
......
......@@ -42,11 +42,12 @@
calendar = new Pikaday({
field: $issuableDueDate.get(0),
theme: 'gitlab-theme',
format: 'YYYY-MM-DD',
format: 'yyyy-mm-dd',
onSelect: function(dateText) {
$issuableDueDate.val(dateFormat(new Date(dateText), 'yyyy-mm-dd'));
}
});
calendar.setDate(new Date($issuableDueDate.val()));
}
}
......
......@@ -3,7 +3,7 @@
require('./flash');
require('vendor/jquery.waitforimages');
require('vendor/task_list');
require('./task_list');
(function() {
var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
......@@ -11,10 +11,16 @@ require('vendor/task_list');
this.Issue = (function() {
function Issue() {
this.submitNoteForm = bind(this.submitNoteForm, this);
// Prevent duplicate event bindings
this.disableTaskList();
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.initMergeRequests();
......@@ -22,11 +28,6 @@ require('vendor/task_list');
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() {
var _this, issueFailMessage;
_this = this;
......@@ -54,16 +55,19 @@ require('vendor/task_list');
success: function(data, textStatus, jqXHR) {
if ('id' in data) {
$(document).trigger('issuable:change');
const currentTotal = Number($('.issue_counter').text());
if (isClose) {
$('a.btn-close').addClass('hidden');
$('a.btn-reopen').removeClass('hidden');
$('div.status-box-closed').removeClass('hidden');
$('div.status-box-open').addClass('hidden');
$('.issue_counter').text(currentTotal - 1);
} else {
$('a.btn-reopen').addClass('hidden');
$('a.btn-close').removeClass('hidden');
$('div.status-box-closed').addClass('hidden');
$('div.status-box-open').removeClass('hidden');
$('.issue_counter').text(currentTotal + 1);
}
} else {
new Flash(issueFailMessage, 'alert');
......@@ -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() {
var $container;
$container = $('#merge-requests');
......
......@@ -231,6 +231,21 @@
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.
*
......@@ -241,5 +256,45 @@
acc[element] = DOMStringMapObject[element];
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);
}).call(this);
......@@ -19,7 +19,7 @@
const calendar = new Pikaday({
field: $input.get(0),
theme: 'gitlab-theme',
format: 'YYYY-MM-DD',
format: 'yyyy-mm-dd',
minDate: new Date(),
onSelect(dateText) {
$input.val(dateFormat(new Date(dateText), 'yyyy-mm-dd'));
......@@ -30,6 +30,7 @@
},
});
calendar.setDate(new Date($input.val()));
$input.data('pikaday', calendar);
});
......
......@@ -2,7 +2,7 @@
/* global MergeRequestTabs */
require('vendor/jquery.waitforimages');
require('vendor/task_list');
require('./task_list');
require('./merge_request_tabs');
(function() {
......@@ -24,12 +24,18 @@ require('./merge_request_tabs');
};
})(this));
this.initTabs();
// Prevent duplicate event bindings
this.disableTaskList();
this.initMRBtnListeners();
this.initCommitMessageListeners();
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');
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() {
var _this;
_this = this;
......@@ -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() {
$(document).on('click', 'a.js-with-description-link', function(e) {
var textarea = $('textarea.js-commit-message');
......
......@@ -103,9 +103,10 @@ require('./flash');
}
clickTab(e) {
if (e.target && gl.utils.isMetaClick(e)) {
const targetLink = e.target.getAttribute('href');
if (e.currentTarget && gl.utils.isMetaClick(e)) {
const targetLink = e.currentTarget.getAttribute('href');
e.stopImmediatePropagation();
e.preventDefault();
window.open(targetLink, '_blank');
}
}
......
......@@ -11,7 +11,7 @@ require('./dropzone_input');
require('./gfm_auto_complete');
require('vendor/jquery.caret'); // required by jquery.atwho
require('vendor/jquery.atwho');
require('vendor/task_list');
require('./task_list');
(function() {
var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
......@@ -51,7 +51,11 @@ require('vendor/task_list');
this.addBinding();
this.setPollingInterval();
this.setupMainTargetNoteForm();
this.initTaskList();
this.taskList = new gl.TaskList({
dataType: 'note',
fieldName: 'note',
selector: '.notes'
});
this.collapseLongCommitList();
// We are in the Merge Requests page so we need another edit form for Changes tab
......@@ -125,8 +129,6 @@ require('vendor/task_list');
$(document).off("keydown", ".js-note-text");
$(document).off('click', '.js-comment-resolve-button');
$(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) {
......@@ -286,7 +288,7 @@ require('vendor/task_list');
// Update datetime format on the recent note
gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false);
this.collapseLongCommitList();
this.initTaskList();
this.taskList.init();
this.refresh();
return this.updateNotesCount(1);
}
......@@ -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) {
var $editForm = $(this.getEditFormSelector($el));
var $note = $el.closest('.note');
......@@ -896,17 +889,6 @@ require('vendor/task_list');
$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) {
return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount);
};
......@@ -923,9 +905,10 @@ require('vendor/task_list');
};
Notes.prototype.toggleCommitList = function(e) {
const $element = $(e.target);
const $element = $(e.currentTarget);
const $closestSystemCommitList = $element.siblings('.system-note-commit-list');
$element.find('.fa').toggleClass('fa-angle-down').toggleClass('fa-angle-up');
$closestSystemCommitList.toggleClass('hide-shade');
};
......
......@@ -38,13 +38,15 @@
this.$buttons.attr('data-status', newStatus);
this.$buttons.find('> span').text(newAction);
for (const button of this.$buttons) {
this.$buttons.map((button) => {
const $button = $(button);
if ($button.attr('data-original-title')) {
$button.tooltip('hide').attr('data-original-title', newAction).tooltip('fixTitle');
}
}
return button;
});
});
}
}
......
......@@ -21,11 +21,16 @@
};
Sidebar.prototype.addEventListeners = function() {
const $document = $(document);
const throttledSetSidebarHeight = _.throttle(this.setSidebarHeight, 10);
this.sidebar.on('click', '.sidebar-collapsed-icon', this, this.sidebarCollapseClicked);
$('.dropdown').on('hidden.gl.dropdown', this, this.onSidebarDropdownHidden);
$('.dropdown').on('loading.gl.dropdown', this.sidebarDropdownLoading);
$('.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;
e.preventDefault();
$this = $(this);
......@@ -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() {
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 @@
goToTodoUrl(e) {
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) {
return;
}
if (gl.utils.isMetaClick(e)) {
const windowTarget = '_blank';
const selected = e.target;
e.preventDefault();
// Meta-Click on username leads to different URL than todoLink.
// Turbolinks can resolve that URL, but window.open requires URL manually.
if (targetLink !== todoLink) {
return window.open(targetLink, '_blank');
if (selected.tagName === 'IMG') {
const avatarUrl = selected.parentElement.getAttribute('href');
return window.open(avatarUrl, windowTarget);
} else {
return window.open(todoLink, '_blank');
return window.open(todoLink, windowTarget);
}
} else {
return gl.utils.visitUrl(todoLink);
......
......@@ -62,6 +62,7 @@
<li v-for='artifact in pipeline.details.artifacts'>
<a
rel="nofollow"
download
:href='artifact.path'
>
<i class="fa fa-download" aria-hidden="true"></i>
......
......@@ -5,6 +5,7 @@ window.Vue = require('vue');
require('../vue_shared/components/table_pagination');
require('./store');
require('../vue_shared/components/pipelines_table');
const CommitPipelinesStoreWithTimeAgo = require('../commit/pipelines/pipelines_store');
((gl) => {
gl.VuePipelines = Vue.extend({
......@@ -28,15 +29,34 @@ require('../vue_shared/components/pipelines_table');
},
props: ['scope', 'store', 'svgs'],
created() {
const pagenum = gl.utils.getParameterByName('p');
const pagenum = gl.utils.getParameterByName('page');
const scope = gl.utils.getParameterByName('scope');
if (pagenum) this.pagenum = pagenum;
if (scope) this.apiScope = scope;
this.store.fetchDataLoop.call(this, Vue, this.pagenum, this.scope, this.apiScope);
},
beforeUpdate() {
if (this.pipelines.length && this.$children) {
CommitPipelinesStoreWithTimeAgo.startTimeAgoLoops.call(this, Vue);
}
},
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) {
gl.utils.visitUrl(`?scope=${apiScope}&p=${pagenum}`);
if (!apiScope) apiScope = 'all';
gl.utils.visitUrl(`?scope=${apiScope}&page=${pagenum}`);
},
},
template: `
......
/* global gl, Flash */
/* eslint-disable no-param-reassign, no-underscore-dangle */
require('../vue_realtime_listener');
/* eslint-disable no-param-reassign */
((gl) => {
const pageValues = (headers) => {
const normalized = gl.utils.normalizeHeaders(headers);
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'],
};
const paginationInfo = gl.utils.parseIntPagination(normalized);
return paginationInfo;
};
gl.PipelineStore = class {
fetchDataLoop(Vue, pageNum, url, apiScope) {
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}`)
return this.$http.get(`${url}?scope=${apiScope}&page=${pageNum}`)
.then((response) => {
const pageInfo = pageValues(response.headers);
this.pageInfo = Object.assign({}, this.pageInfo, pageInfo);
......@@ -38,31 +21,11 @@ require('../vue_realtime_listener');
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();
const removeIntervals = () => clearInterval(this.timeLoopInterval);
const startIntervals = () => startTimeLoops();
gl.VueRealtimeListener(removeIntervals, startIntervals);
}
};
})(window.gl || (window.gl = {}));
/* global Vue */
window.Vue = require('vue');
(() => {
window.gl = window.gl || {};
......
......@@ -57,9 +57,7 @@ window.Vue = require('vue');
},
methods: {
changePage(e) {
let apiScope = gl.utils.getParameterByName('scope');
if (!apiScope) apiScope = 'all';
const apiScope = gl.utils.getParameterByName('scope');
const text = e.target.innerText;
const { totalPages, nextPage, previousPage } = this.pageInfo;
......
......@@ -19,7 +19,6 @@
@import "framework/flash.scss";
@import "framework/forms.scss";
@import "framework/gfm.scss";
@import "framework/gitlab-theme.scss";
@import "framework/header.scss";
@import "framework/highlight.scss";
@import "framework/issue_box.scss";
......
......@@ -116,7 +116,7 @@
}
.btn,
.side-nav-toggle {
.global-dropdown-toggle {
@include transition(background-color, border-color, color, box-shadow);
}
......@@ -140,7 +140,6 @@ a {
@include transition(background-color, box-shadow);
}
.nav-sidebar a,
.dropdown-menu a,
.dropdown-menu button,
.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,10 +100,30 @@ header {
}
}
}
}
.side-nav-toggle {
.global-dropdown {
position: absolute;
left: -10px;
.badge {
font-size: 11px;
}
li {
.active a {
font-weight: bold;
}
&:hover {
.badge {
background-color: $white-light;
}
}
}
}
.global-dropdown-toggle {
margin: 7px 0;
font-size: 18px;
padding: 6px 10px;
......@@ -115,7 +135,6 @@ header {
color: $gl-header-nav-hover-color;
}
}
}
.header-content {
position: relative;
......
.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 {
width: 100%;
transition: padding $sidebar-transition-duration;
......@@ -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 {
padding: 10px $gl-sidebar-padding;
color: inherit;
......@@ -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 {
padding-right: 0;
@media (min-width: $screen-sm-min) {
.content-wrapper {
padding-right: $sidebar_collapsed_width;
padding-right: $gutter_collapsed_width;
}
.merge-request-tabs-holder.affix {
right: $sidebar_collapsed_width;
right: $gutter_collapsed_width;
}
}
......@@ -231,7 +56,7 @@ header.header-sidebar-pinned {
@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
&:not(.build-sidebar):not(.wiki-sidebar) {
padding-right: $sidebar_collapsed_width;
padding-right: $gutter_collapsed_width;
}
}
......@@ -245,12 +70,12 @@ header.header-sidebar-pinned {
}
&.with-overlay .merge-request-tabs-holder.affix {
right: $sidebar_collapsed_width;
right: $gutter_collapsed_width;
}
}
&.with-overlay {
padding-right: $sidebar_collapsed_width;
padding-right: $gutter_collapsed_width;
}
}
......
/*
* Layout
*/
$sidebar_collapsed_width: 62px;
$sidebar_width: 220px;
$gutter_collapsed_width: 62px;
$gutter_width: 290px;
$gutter_inner_width: 250px;
......
......@@ -91,7 +91,7 @@
}
&.scroll-top {
top: 110px;
top: 10px;
}
&.scroll-bottom {
......
......@@ -10,6 +10,11 @@
font-size: 34px;
}
.environments-folder-name {
font-weight: normal;
padding-top: 20px;
}
@media (max-width: $screen-xs-max) {
.environments-container {
width: 100%;
......@@ -110,17 +115,20 @@
}
}
.children-row .environment-name {
margin-left: 17px;
margin-right: -17px;
}
.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 {
cursor: pointer;
color: $gl-text-color-secondary;
display: inline-block;
}
}
......
......@@ -253,11 +253,11 @@
display: block;
}
width: $sidebar_collapsed_width;
width: $gutter_collapsed_width;
padding-top: 0;
.block {
width: $sidebar_collapsed_width - 2px;
width: $gutter_collapsed_width - 2px;
margin-left: -19px;
padding: 15px 0 0;
border-bottom: none;
......
......@@ -72,6 +72,7 @@ ul.notes {
overflow: hidden;
.system-note-commit-list-toggler {
color: $gl-link-color;
display: none;
padding: 10px 0 0;
cursor: pointer;
......@@ -107,16 +108,6 @@ ul.notes {
display: none;
}
p:last-child {
a {
color: $gl-text-color;
&:hover {
color: $gl-link-color;
}
}
}
&::after {
content: '';
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 {
label {
margin-right: 20px;
......
......@@ -102,6 +102,7 @@
font-size: 24px;
font-weight: 400;
line-height: 1;
word-wrap: break-word;
.fa {
margin-left: 2px;
......
......@@ -149,7 +149,7 @@
}
.commit-actions {
width: 200px;
width: 260px;
}
}
......
......@@ -31,7 +31,6 @@ nav.navbar-collapse.collapse,
.blob-commit-info,
.file-title,
.file-holder,
.sidebar-wrapper,
.nav,
.btn,
ul.notes-form,
......
......@@ -13,7 +13,7 @@ class Admin::RunnersController < Admin::ApplicationController
end
def update
if @runner.update_attributes(runner_params)
if Ci::UpdateRunnerService.new(@runner).update(runner_params)
respond_to do |format|
format.js
format.html { redirect_to admin_runner_path(@runner) }
......@@ -31,7 +31,7 @@ class Admin::RunnersController < Admin::ApplicationController
end
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.'
else
redirect_to admin_runners_path, alert: 'Runner was not updated.'
......@@ -39,7 +39,7 @@ class Admin::RunnersController < Admin::ApplicationController
end
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.'
else
redirect_to admin_runners_path, alert: 'Runner was not updated.'
......
......@@ -194,7 +194,6 @@ class Admin::UsersController < Admin::ApplicationController
:provider,
:remember_me,
:skype,
:theme_id,
:twitter,
:username,
:website_url
......
......@@ -34,7 +34,6 @@ class Profiles::PreferencesController < Profiles::ApplicationController
:layout,
:dashboard,
:project_view,
:theme_id
)
end
end
......@@ -83,7 +83,6 @@ class Projects::ApplicationController < ApplicationController
end
def apply_diff_view_cookie!
@show_changes_tab = params[:view].present?
cookies.permanent[:diff_view] = params.delete(:view) if params[:view].present?
end
......
......@@ -9,15 +9,40 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :verify_api_request!, only: :terminal_websocket_authorize
def index
@scope = params[:scope]
@environments = project.environments.includes(:last_deployment)
@environments = project.environments
.with_state(params[:scope] || :available)
respond_to do |format|
format.html
format.json do
render json: EnvironmentSerializer
.new(project: @project, user: current_user)
.represent(@environments)
render json: {
environments: EnvironmentSerializer
.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
......
......@@ -248,6 +248,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
respond_to do |format|
format.html do
define_new_vars
@show_changes_tab = true
render "new"
end
format.json do
......@@ -395,10 +396,13 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
def merge_widget_refresh
if merge_request.in_progress_merge_commit_sha || merge_request.state == 'merged'
@status = :success
elsif merge_request.merge_when_build_succeeds
if merge_request.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
render 'merge'
......@@ -674,6 +678,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController
@labels = LabelsFinder.new(current_user, project_id: @project.id).execute
@show_changes_tab = params[:show_changes].present?
define_pipelines_vars
end
......
......@@ -12,7 +12,7 @@ class Projects::RunnersController < Projects::ApplicationController
end
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.'
else
render 'edit'
......@@ -28,7 +28,7 @@ class Projects::RunnersController < Projects::ApplicationController
end
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.'
else
redirect_to runner_path(@runner), alert: 'Runner was not updated.'
......@@ -36,7 +36,7 @@ class Projects::RunnersController < Projects::ApplicationController
end
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.'
else
redirect_to runner_path(@runner), alert: 'Runner was not updated.'
......
......@@ -27,7 +27,7 @@ class Projects::TagsController < Projects::ApplicationController
end
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])
if result[:status] == :success
......@@ -41,7 +41,7 @@ class Projects::TagsController < Projects::ApplicationController
end
def destroy
DeleteTagService.new(project, current_user).execute(params[:id])
Tags::DestroyService.new(project, current_user).execute(params[:id])
respond_to do |format|
format.html do
......
......@@ -300,4 +300,13 @@ module ApplicationHelper
def page_class
"issue-boards-page" if current_controller?(:boards)
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
module NavHelper
def page_sidebar_class
if pinned_nav?
"page-sidebar-expanded page-sidebar-pinned"
end
end
def page_gutter_class
if current_path?('merge_requests#show') ||
current_path?('merge_requests#diffs') ||
......@@ -32,10 +26,6 @@ module NavHelper
class_name = ''
class_name << " with-horizontal-nav" if defined?(nav) && nav
if pinned_nav?
class_name << " header-sidebar-expanded header-sidebar-pinned"
end
class_name
end
......@@ -46,8 +36,4 @@ module NavHelper
def nav_control_class
"nav-control" if current_user
end
def pinned_nav?
cookies[:pin_nav] == 'true'
end
end
......@@ -34,6 +34,10 @@ module PageLayoutHelper
end
end
def favicon
Rails.env.development? ? 'favicon-blue.ico' : 'favicon.ico'
end
def page_image
default = image_url('gitlab_logo.png')
......
......@@ -41,10 +41,6 @@ module PreferencesHelper
]
end
def user_application_theme
Gitlab::Themes.for_user(current_user).css_class
end
def user_color_scheme
Gitlab::ColorSchemes.for_user(current_user).css_class
end
......
......@@ -63,33 +63,10 @@ module Ci
new_build.save
end
def retry(build, user = nil)
new_build = Ci::Build.create(
ref: build.ref,
tag: build.tag,
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
def retry(build, current_user)
Ci::RetryBuildService
.new(build.project, current_user)
.execute(build)
end
end
......@@ -137,7 +114,7 @@ module Ci
project.builds_enabled? && commands.present? && manual? && skipped?
end
def play(current_user = nil)
def play(current_user)
# Try to queue a current build
if self.enqueue
self.update(user: current_user)
......
......@@ -214,21 +214,17 @@ module Ci
def cancel_running
Gitlab::OptimisticLocking.retry_lock(
statuses.cancelable) do |cancelable|
cancelable.each(&:cancel)
cancelable.find_each(&:cancel)
end
end
def retry_failed(user)
Gitlab::OptimisticLocking.retry_lock(
builds.latest.failed_or_canceled) do |failed_or_canceled|
failed_or_canceled.select(&:retryable?).each do |build|
Ci::Build.retry(build, user)
end
end
def retry_failed(current_user)
Ci::RetryPipelineService.new(project, current_user)
.execute(self)
end
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
def latest?
......
......@@ -22,8 +22,6 @@ module Ci
scope :online, ->() { where('contacted_at > ?', LAST_CONTACT_TIME) }
scope :ordered, ->() { order(id: :desc) }
after_save :tick_runner_queue, if: :form_editable_changed?
scope :owned_or_shared, ->(project_id) do
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)
......@@ -40,6 +38,8 @@ module Ci
acts_as_taggable
after_destroy :cleanup_runner_queue
# Searches for runners matching the given query.
#
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
......@@ -147,14 +147,14 @@ module Ci
private
def runner_queue_key
"runner:build_queue:#{self.token}"
def cleanup_runner_queue
Gitlab::Redis.with do |redis|
redis.del(runner_queue_key)
end
def form_editable_changed?
FORM_EDITABLE.any? do |editable|
public_send("#{editable}_changed?")
end
def runner_queue_key
"runner:build_queue:#{self.token}"
end
def tag_constraints
......
......@@ -23,9 +23,6 @@ class CommitStatus < ActiveRecord::Base
where(id: max_id.group(:name, :commit_id))
end
scope :retried, -> { where.not(id: latest) }
scope :ordered, -> { order(:name) }
scope :failed_but_allowed, -> do
where(allow_failure: true, status: [:failed, :canceled])
end
......@@ -36,8 +33,11 @@ class CommitStatus < ActiveRecord::Base
false, all_state_names - [:failed, :canceled])
end
scope :retried, -> { where.not(id: latest) }
scope :ordered, -> { order(:name) }
scope :latest_ordered, -> { latest.ordered.includes(project: :namespace) }
scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) }
scope :after_stage, -> (index) { where('stage_idx > ?', index) }
state_machine :status do
event :enqueue do
......
......@@ -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') }
# 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
scope :root, -> { where('type IS NULL') }
......@@ -216,6 +216,14 @@ class Namespace < ActiveRecord::Base
parent_id_changed?
end
def prepare_for_destroy
old_repository_storage_paths
end
def old_repository_storage_paths
@old_repository_storage_paths ||= repository_storage_paths
end
private
def repository_storage_paths
......@@ -229,7 +237,7 @@ class Namespace < ActiveRecord::Base
def rm_dir
# 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.
# We will remove it later async
new_path = "#{path}+#{id}+deleted"
......
......@@ -234,6 +234,8 @@ class Project < ActiveRecord::Base
# Scopes
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_stars, -> { reorder('projects.star_count DESC') }
......
......@@ -30,5 +30,9 @@ module ChatMessage
def attachment_color
'#345'
end
def link(text, url)
"[#{text}](#{url})"
end
end
end
......@@ -7,7 +7,11 @@ module ChatMessage
attr_reader :project_name
attr_reader :project_url
attr_reader :user_name
attr_reader :user_url
attr_reader :duration
attr_reader :stage
attr_reader :build_id
attr_reader :build_name
def initialize(params)
@sha = params[:sha]
......@@ -17,7 +21,11 @@ module ChatMessage
@project_url = params[:project_url]
@status = params[:commit][:status]
@user_name = params[:commit][:author_name]
@user_url = params[:commit][:author_url]
@duration = params[:commit][:duration]
@stage = params[:build_stage]
@build_name = params[:build_name]
@build_id = params[:build_id]
end
def pretext
......@@ -35,7 +43,19 @@ module ChatMessage
private
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
def format(string)
......@@ -64,11 +84,11 @@ module ChatMessage
end
def branch_link
"[#{ref}](#{branch_url})"
link(ref, branch_url)
end
def project_link
"[#{project_name}](#{project_url})"
link(project_name, project_url)
end
def commit_url
......@@ -76,7 +96,7 @@ module ChatMessage
end
def commit_link
"[#{Commit.truncate_sha(sha)}](#{commit_url})"
link(Commit.truncate_sha(sha), commit_url)
end
end
end
......@@ -55,11 +55,11 @@ module ChatMessage
end
def project_link
"[#{project_name}](#{project_url})"
link(project_name, project_url)
end
def issue_link
"[#{issue_title}](#{issue_url})"
link(issue_title, issue_url)
end
def issue_title
......
......@@ -43,7 +43,7 @@ module ChatMessage
end
def project_link
"[#{project_name}](#{project_url})"
link(project_name, project_url)
end
def merge_request_message
......@@ -51,7 +51,7 @@ module ChatMessage
end
def merge_request_link
"[merge request !#{merge_request_id}](#{merge_request_url})"
link("merge request !#{merge_request_id}", merge_request_url)
end
def merge_request_url
......
......@@ -3,10 +3,9 @@ module ChatMessage
attr_reader :message
attr_reader :user_name
attr_reader :project_name
attr_reader :project_link
attr_reader :project_url
attr_reader :note
attr_reader :note_url
attr_reader :title
def initialize(params)
params = HashWithIndifferentAccess.new(params)
......@@ -69,15 +68,15 @@ module ChatMessage
end
def description_message
[{ text: format(@note), color: attachment_color }]
[{ text: format(note), color: attachment_color }]
end
def project_link
"[#{@project_name}](#{@project_url})"
link(project_name, project_url)
end
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
# 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
}
end
def repository_storage_path
project.repository_storage_path
end
private
def init_repo(path_with_namespace)
......
......@@ -27,7 +27,7 @@ class Service < ActiveRecord::Base
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 :external_wikis, -> { where(type: 'ExternalWikiService').active }
scope :active, -> { where(active: true) }
......
......@@ -23,7 +23,6 @@ class User < ActiveRecord::Base
default_value_for :can_create_team, false
default_value_for :hide_no_ssh_key, false
default_value_for :hide_no_password, false
default_value_for :theme_id, gitlab_config.default_theme
attr_encrypted :otp_secret,
key: Gitlab::Application.secrets.otp_key_base,
......
......@@ -20,8 +20,6 @@ class EnvironmentSerializer < BaseSerializer
end
def represent(resource, opts = {})
resource = @paginator.paginate(resource) if paginated?
if itemized?
itemize(resource).map do |item|
{ name: item.name,
......@@ -29,6 +27,8 @@ class EnvironmentSerializer < BaseSerializer
latest: super(item.latest, opts) }
end
else
resource = @paginator.paginate(resource) if paginated?
super(resource, opts)
end
end
......@@ -36,15 +36,20 @@ class EnvironmentSerializer < BaseSerializer
private
def itemize(resource)
items = resource.group(:item_name).order('item_name ASC')
.pluck('COALESCE(environment_type, name) AS item_name',
'COUNT(*) AS environments_count',
'MAX(id) AS last_environment_id')
items = resource.order('folder_name ASC')
.group('COALESCE(environment_type, name)')
.select('COALESCE(environment_type, name) AS folder_name',
'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|
Item.new(name, size, environments[id])
items.map do |item|
Item.new(item.folder_name, item.size, environments[item.last_id])
end
end
end
class BaseService
include Gitlab::Allowable
include Gitlab::CurrentSettings
attr_accessor :project, :current_user, :params
......@@ -7,10 +8,6 @@ class BaseService
@project, @current_user, @params = project, user, params.dup
end
def can?(object, action, subject)
Ability.allowed?(object, action, subject)
end
def notification_service
NotificationService.new
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
end
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.
# Skip repository removal because we remove directory with namespace
# that contain all these repositories
......
......@@ -18,5 +18,11 @@ module MergeRequests
todo_service.merge_request_build_retried(merge_request)
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
......@@ -19,17 +19,24 @@ module MergeRequests
unless @merge_request.mergeable?
return log_merge_error('Merge request is not mergeable', save_message_on_model: true)
end
<<<<<<< HEAD
if @merge_request.target_project.above_size_limit?
message = Gitlab::RepositorySizeError.new(@merge_request.target_project).merge_error
return log_merge_error(message, save_message_on_model: true)
end
=======
>>>>>>> ce/master
@source = find_merge_source
unless @source
<<<<<<< HEAD
return log_merge_error('No source for merge', save_message_on_model: true)
=======
log_merge_error('No source for merge', save_message_on_model: true)
>>>>>>> ce/master
end
merge_request.in_locked_state do
......
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
escaped_filename = filename.gsub("]", "\\]")
markdown = "[#{escaped_filename}](#{self.secure_url})"
markdown.prepend("!") if image_or_video?
markdown.prepend("!") if image_or_video? || dangerous?
{
alt: filename,
......
# Extra methods for uploader
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
# 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
# on IE >= 9.
# http://archive.sublimevideo.info/20150912/docs.sublimevideo.net/troubleshooting.html
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?
extension_match?(IMAGE_EXT)
......@@ -20,6 +23,10 @@ module UploaderHelper
image? || video?
end
def dangerous?
extension_match?(DANGEROUS_EXT)
end
def extension_match?(extensions)
return false unless file
......
......@@ -12,6 +12,7 @@
%th.wide Message
%th Action
= render @abuse_reports
= paginate @abuse_reports, theme: 'gitlab'
- else
.empty-state
.text-center
......
......@@ -205,7 +205,7 @@
= f.label :max_pages_size, 'Maximum size of pages (MB)', class: 'control-label col-sm-2'
.col-sm-10
= f.number_field :max_pages_size, class: 'form-control'
.help-block Zero for unlimited
.help-block 0 for unlimited
%fieldset
%legend Continuous Integration
......@@ -581,7 +581,7 @@
= f.number_field :terminal_max_session_time, class: 'form-control'
.help-block
Maximum time for web terminal websocket connection (in seconds).
Set to 0 for unlimited time.
0 for unlimited.
.form-actions
= f.submit 'Save', class: 'btn btn-save'
......@@ -8,15 +8,14 @@
%div{ class: container_class }
%ul.nav-links.log-tabs
- 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}",
'data-toggle' => 'tab'
.row-content-block
To prevent performance issues admin logs output the last 2000 lines
.tab-content
- loggers.each do |klass|
.tab-pane{ class: (klass == Gitlab::GitLogger ? 'active' : ''),
id: klass::file_name_noext }
.tab-pane{ class: active_when(klass == Gitlab::GitLogger), id: klass::file_name_noext }
.file-holder#README
.js-file-title.file-title
%i.fa.fa-file
......
......@@ -48,13 +48,13 @@
= link_to admin_projects_path do
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
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
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
Public
......
......@@ -22,7 +22,7 @@
%td
#{runner.builds.count(:all)}
%td
- runner.tag_list.each do |tag|
- runner.tag_list.sort.each do |tag|
%span.label.label-primary
= tag
%td
......
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.
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