Commit 8a99cf20 authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch 'master' into 28248-use-page

* master: (65 commits)
  Add CHANGELOG file
  Fix positioning of top scroll button
  add space between ci text and commit sha in Merge Request widget
  Do not use single quote in headings as it breaks docs.gitlab.com
  Fix broken test
  Update services templates docs
  Fix error in MR widget after /merge slash command
  Remove arrow icon from folders
  Create util to handle pagination transformation
  Wrap long Project and Group titles
  Changes after review
  Changes after review
  Rename storePagination to setPagination
  Transforms startTimeAgoLoops into a static method so we can reuse it instead of have 2
  Only run timeago loops after rendering timeago components
  fix tmpdir
  Fix broken tests
  Show merge errors in merge request widget
  Move babel config for instanbul to karma config (!9286)
  Clarify that stage is needed to stop environments
  ...
parents d1c2cf4a 0aaf22de
......@@ -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,6 +78,12 @@ require('./pipelines_store');
});
},
beforeUpdate() {
if (this.state.pipelines.length && this.$children) {
PipelineStore.startTimeAgoLoops.call(this, Vue);
}
},
template: `
<div class="pipelines">
<div class="realtime-loading" v-if="isLoading">
......
/* global Vue */
window.Vue = require('vue');
(() => {
window.gl = window.gl || {};
window.gl.environmentsList = window.gl.environmentsList || {};
gl.environmentsList.ActionsComponent = Vue.component('actions-component', {
props: {
actions: {
type: Array,
required: false,
default: () => [],
},
playIconSvg: {
type: String,
required: false,
},
const Vue = require('vue');
module.exports = Vue.component('actions-component', {
props: {
actions: {
type: Array,
required: false,
default: () => [],
},
template: `
<div class="inline">
<div class="dropdown">
<a class="dropdown-new btn btn-default" data-toggle="dropdown">
<span class="js-dropdown-play-icon-container" v-html="playIconSvg"></span>
<i class="fa fa-caret-down"></i>
</a>
<ul class="dropdown-menu dropdown-menu-align-right">
<li v-for="action in actions">
<a :href="action.play_path"
data-method="post"
rel="nofollow"
class="js-manual-action-link">
<span class="js-action-play-icon-container" v-html="playIconSvg"></span>
<span>
{{action.name}}
</span>
</a>
</li>
</ul>
</div>
playIconSvg: {
type: String,
required: false,
},
},
template: `
<div class="inline">
<div class="dropdown">
<a class="dropdown-new btn btn-default" data-toggle="dropdown">
<span class="js-dropdown-play-icon-container" v-html="playIconSvg"></span>
<i class="fa fa-caret-down"></i>
</a>
<ul class="dropdown-menu dropdown-menu-align-right">
<li v-for="action in actions">
<a :href="action.play_path"
data-method="post"
rel="nofollow"
class="js-manual-action-link">
<span class="js-action-play-icon-container" v-html="playIconSvg"></span>
<span>
{{action.name}}
</span>
</a>
</li>
</ul>
</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', {
props: {
externalUrl: {
type: String,
default: '',
},
module.exports = Vue.component('external-url-component', {
props: {
externalUrl: {
type: String,
default: '',
},
},
template: `
<a class="btn external_url" :href="externalUrl" target="_blank">
<i class="fa fa-external-link"></i>
</a>
`,
});
})();
template: `
<a class="btn external_url" :href="externalUrl" target="_blank">
<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', {
props: {
retryUrl: {
type: String,
default: '',
},
module.exports = Vue.component('rollback-component', {
props: {
retryUrl: {
type: String,
default: '',
},
isLastDeployment: {
type: Boolean,
default: true,
},
isLastDeployment: {
type: Boolean,
default: true,
},
},
template: `
<a class="btn" :href="retryUrl" data-method="post" rel="nofollow">
<span v-if="isLastDeployment">
Re-deploy
</span>
<span v-else>
Rollback
</span>
</a>
`,
});
})();
template: `
<a class="btn" :href="retryUrl" data-method="post" rel="nofollow">
<span v-if="isLastDeployment">
Re-deploy
</span>
<span v-else>
Rollback
</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', {
props: {
stopUrl: {
type: String,
default: '',
},
module.exports = Vue.component('stop-component', {
props: {
stopUrl: {
type: String,
default: '',
},
},
template: `
<a class="btn stop-env-link"
:href="stopUrl"
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>
</a>
`,
});
})();
template: `
<a class="btn stop-env-link"
:href="stopUrl"
data-confirm="Are you sure you want to stop this environment?"
data-method="post"
rel="nofollow">
<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', {
props: {
terminalPath: {
type: String,
default: '',
},
terminalIconSvg: {
type: String,
default: '',
},
module.exports = Vue.component('terminal-button-component', {
props: {
terminalPath: {
type: String,
default: '',
},
terminalIconSvg: {
type: String,
default: '',
},
},
template: `
<a class="btn terminal-button"
:href="terminalPath">
<span class="js-terminal-icon-container" v-html="terminalIconSvg"></span>
</a>
`,
});
})();
template: `
<a class="btn terminal-button"
:href="terminalPath">
<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() {
this.state.environments = [];
this.state.stoppedCounter = 0;
this.state.availableCounter = 0;
this.state.visibility = 'available';
this.state.filteredEnvironments = [];
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.
*
*
* @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}
* ]
*
*
* @param {Array} environments List of environments.
* @returns {Array} Tree structured array with the received environments.
*/
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;
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'],
});
}
} else {
acc.push(environment);
}
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 this.filterState(this.state.visibility, item);
}).filter(Boolean);
this.state.filteredEnvironments = filteredEnvironments;
return filteredEnvironments;
},
/**
* Given the state and the environment,
* returns only if the environment state matches the one provided.
*
* @param {String} state
* @param {Object} environment
* @return {Object}
*/
filterState(state, environment) {
return environment.state === state && environment;
},
/**
* Toggles folder open property given the environment type.
*
* @param {String} envType
* @return {Array}
*/
toggleFolder(envType) {
const environments = this.state.environments;
const environmentsCopy = environments.map((env) => {
if (env['vue-isChildren'] && env.name === envType) {
env.isOpen = !env.isOpen;
}
return env;
});
this.state.environments = environmentsCopy;
return environmentsCopy;
},
/**
* Given an array of environments, returns the number of environments
* that have the given state.
*
* @param {Array} environments
* @param {String} state
* @returns {Number}
*/
countByState(environments, state) {
return environments.filter(env => env.state === state).length;
},
/**
* Sorts the two objects provided by their name.
*
* @param {Object} a
* @param {Object} b
* @returns {Number}
*/
sortByName(a, b) {
const nameA = a.name.toUpperCase();
const nameB = b.name.toUpperCase();
return nameA < nameB ? -1 : nameA > nameB ? 1 : 0; // eslint-disable-line
},
};
})();
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.paginationInformation = {};
return this;
}
/**
*
* Stores the received environments.
*
* 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
* @returns {Array}
*/
storeEnvironments(environments = []) {
const filteredEnvironments = environments.map((env) => {
let filtered = {};
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 {
filtered = Object.assign(filtered, env);
}
return filtered;
});
this.state.environments = filteredEnvironments;
return filteredEnvironments;
}
setPagination(pagination = {}) {
const normalizedHeaders = gl.utils.normalizeHeaders(pagination);
const paginationInformation = gl.utils.parseIntPagination(normalizedHeaders);
this.state.paginationInformation = paginationInformation;
return paginationInformation;
}
/**
* Stores the number of available environments.
*
* @param {Number} count = 0
* @return {Number}
*/
storeAvailableCount(count = 0) {
this.state.availableCounter = count;
return count;
}
/**
* Stores the number of closed environments.
*
* @param {Number} count = 0
* @return {Number}
*/
storeStoppedCount(count = 0) {
this.state.stoppedCounter = count;
return count;
}
}
module.exports = EnvironmentsStore;
......@@ -54,16 +54,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');
......
......@@ -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);
......@@ -110,7 +110,7 @@ require('./smart_interval');
urlSuffix = deleteSourceBranch ? '?deleted_source_branch=true' : '';
return window.location.href = window.location.pathname + urlSuffix;
} else if (data.merge_error) {
return _this.$widgetBody.html("<h4>" + data.merge_error + "</h4>");
return $('.mr-widget-body').html("<h4>" + data.merge_error + "</h4>");
} else {
callback = function() {
return merge_request_widget.mergeInProgress(deleteSourceBranch);
......
......@@ -923,9 +923,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');
};
......
......@@ -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({
......@@ -32,10 +33,26 @@ require('../vue_shared/components/pipelines_table');
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: {
change(pagenum, apiScope) {
/**
* Changes the URL according to the pagination component.
*
* If no scope is provided, 'all' is assumed.
*
* @param {Number} pagenum
* @param {String} apiScope = 'all'
*/
change(pagenum, apiScope = 'all') {
gl.utils.visitUrl(`?scope=${apiScope}&page=${pagenum}`);
},
},
......
/* 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.normalizeHeaders(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}`)
.then((response) => {
const pageInfo = pageValues(response.headers);
this.pageInfo = Object.assign({}, this.pageInfo, pageInfo);
const res = JSON.parse(response.body);
this.count = Object.assign({}, this.count, res.count);
this.pipelines = Object.assign([], this.pipelines, res.pipelines);
updatePipelineNums(this.count);
this.pageRequest = false;
}, () => {
this.pageRequest = false;
return new Flash('An error occurred while fetching the pipelines, please reload the page again.');
});
goFetch();
const startTimeLoops = () => {
this.timeLoopInterval = setInterval(() => {
this.$children[0].$children.reduce((acc, component) => {
const timeAgoComponent = component.$children.filter(el => el.$options._componentTag === 'time-ago')[0];
acc.push(timeAgoComponent);
return acc;
}, []).forEach(e => e.changeTime());
}, 10000);
};
startTimeLoops();
return this.$http.get(`${url}?scope=${apiScope}&page=${pageNum}`)
.then((response) => {
const pageInfo = pageValues(response.headers);
this.pageInfo = Object.assign({}, this.pageInfo, pageInfo);
const removeIntervals = () => clearInterval(this.timeLoopInterval);
const startIntervals = () => startTimeLoops();
const res = JSON.parse(response.body);
this.count = Object.assign({}, this.count, res.count);
this.pipelines = Object.assign([], this.pipelines, res.pipelines);
gl.VueRealtimeListener(removeIntervals, startIntervals);
this.pageRequest = false;
}, () => {
this.pageRequest = false;
return new Flash('An error occurred while fetching the pipelines, please reload the page again.');
});
}
};
})(window.gl || (window.gl = {}));
/* 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;
......
......@@ -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;
}
}
......@@ -135,4 +143,4 @@
margin-right: 0;
}
}
}
\ No newline at end of file
}
......@@ -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%;
......
......@@ -102,6 +102,7 @@
font-size: 24px;
font-weight: 400;
line-height: 1;
word-wrap: break-word;
.fa {
margin-left: 2px;
......
......@@ -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
......
......@@ -369,10 +369,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'
......
......@@ -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')
......
......@@ -42,7 +42,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') }
......@@ -211,6 +211,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
......@@ -224,7 +232,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"
......
......@@ -214,6 +214,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') }
......
......@@ -160,6 +160,10 @@ class ProjectWiki
}
end
def repository_storage_path
project.repository_storage_path
end
private
def init_repo(path_with_namespace)
......
......@@ -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
......@@ -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
......
......@@ -11,18 +11,20 @@ module MergeRequests
def execute(merge_request)
@merge_request = merge_request
return log_merge_error('Merge request is not mergeable', true) unless @merge_request.mergeable?
unless @merge_request.mergeable?
return log_merge_error('Merge request is not mergeable', save_message_on_model: true)
end
@source = find_merge_source
return log_merge_error('No source for merge', true) unless @source
unless @source
log_merge_error('No source for merge', save_message_on_model: true)
end
merge_request.in_locked_state do
if commit
after_merge
success
else
log_merge_error('Can not merge changes', true)
end
end
end
......@@ -43,11 +45,11 @@ module MergeRequests
if commit_id
merge_request.update(merge_commit_sha: commit_id)
else
merge_request.update(merge_error: 'Conflicts detected during merge')
log_merge_error('Conflicts detected during merge', save_message_on_model: true)
false
end
rescue GitHooksService::PreReceiveError => e
merge_request.update(merge_error: e.message)
log_merge_error(e.message, save_message_on_model: true)
false
rescue StandardError => e
merge_request.update(merge_error: "Something went wrong during merge: #{e.message}")
......@@ -70,10 +72,10 @@ module MergeRequests
@merge_request.force_remove_source_branch? ? @merge_request.author : current_user
end
def log_merge_error(message, http_error = false)
def log_merge_error(message, save_message_on_model: false)
Rails.logger.error("MergeService ERROR: #{merge_request_info} - #{message}")
error(message) if http_error
@merge_request.update(merge_error: message) if save_message_on_model
end
def merge_request_info
......
......@@ -23,7 +23,7 @@
%title= page_title(site_name)
%meta{ name: "description", content: page_description }
= favicon_link_tag 'favicon.ico'
= favicon_link_tag favicon
= stylesheet_link_tag "application", media: "all"
= stylesheet_link_tag "print", media: "print"
......
......@@ -8,19 +8,19 @@
%ul.dropdown-menu.dropdown-menu-align-right{ role: 'menu' }
%li.dropdown-header Source code
%li
= link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'zip'), rel: 'nofollow' do
= link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'zip'), rel: 'nofollow', download: '' do
%i.fa.fa-download
%span Download zip
%li
= link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'tar.gz'), rel: 'nofollow' do
= link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'tar.gz'), rel: 'nofollow', download: '' do
%i.fa.fa-download
%span Download tar.gz
%li
= link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'tar.bz2'), rel: 'nofollow' do
= link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'tar.bz2'), rel: 'nofollow', download: '' do
%i.fa.fa-download
%span Download tar.bz2
%li
= link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'tar'), rel: 'nofollow' do
= link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'tar'), rel: 'nofollow', download: '' do
%i.fa.fa-download
%span Download tar
......@@ -36,6 +36,6 @@
%li.dropdown-header Previous Artifacts
- artifacts.each do |job|
%li
= link_to latest_succeeded_namespace_project_artifacts_path(project.namespace, project, "#{ref}/download", job: job.name), rel: 'nofollow' do
= link_to latest_succeeded_namespace_project_artifacts_path(project.namespace, project, "#{ref}/download", job: job.name), rel: 'nofollow', download: '' do
%i.fa.fa-download
%span Download '#{job.name}'
......@@ -78,7 +78,7 @@
%ul.dropdown-menu.dropdown-menu-align-right
- artifacts.each do |build|
%li
= link_to download_namespace_project_build_artifacts_path(pipeline.project.namespace, pipeline.project, build), rel: 'nofollow' do
= link_to download_namespace_project_build_artifacts_path(pipeline.project.namespace, pipeline.project, build), rel: 'nofollow', download: '' do
= icon("download")
%span Download '#{build.name}' artifacts
......
- @no_container = true
- page_title "Environments"
= render "projects/pipelines/head"
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag("environments_folder")
#environments-folder-list-view{ data: { "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s,
"can-read-environment" => can?(current_user, :read_environment, @project).to_s,
"css-class" => container_class,
"commit-icon-svg" => custom_icon("icon_commit"),
"terminal-icon-svg" => custom_icon("icon_terminal"),
"play-icon-svg" => custom_icon("icon_play") } }
......@@ -28,7 +28,7 @@
%span
CI job
= ci_label_for_status(status)
for
for
- commit = @merge_request.diff_head_commit
= succeed "." do
= link_to commit.short_id, namespace_project_commit_path(@merge_request.source_project.namespace, @merge_request.source_project, commit), class: "monospace"
......
......@@ -77,6 +77,7 @@
- if note.system
.system-note-commit-list-toggler
Toggle commit list
%i.fa.fa-angle-down
- if note.attachment.url
.note-attachment
- if note.attachment.image?
......
......@@ -7,7 +7,7 @@
= snippet.title
by
= link_to user_snippets_path(snippet.author) do
= image_tag avatar_icon(snippet.author_email), class: "avatar avatar-inline s16", alt: ''
= image_tag avatar_icon(snippet.author), class: "avatar avatar-inline s16", alt: ''
= snippet.author_name
%span.light= time_ago_with_tooltip(snippet.created_at)
%h4.snippet-title
......
......@@ -18,6 +18,6 @@
%span
by
= link_to user_snippets_path(snippet_title.author) do
= image_tag avatar_icon(snippet_title.author_email), class: "avatar avatar-inline s16", alt: ''
= image_tag avatar_icon(snippet_title.author), class: "avatar avatar-inline s16", alt: ''
= snippet_title.author_name
%span.light= time_ago_with_tooltip(snippet_title.created_at)
---
title: don't animate logo when downloading files
merge_request:
author:
---
title: update issue count when closing/reopening an issue
merge_request:
author:
---
title: Only return target project's comments for a commit
merge_request:
author:
---
title: Show merge errors in merge request widget
merge_request: 9229
author:
---
title: Fix error in MR widget after /merge slash command
merge_request: 9259
author:
---
title: Only run timeago loops after rendering timeago components
merge_request:
author:
---
title: Fix positioning of `Scroll to top` button
merge_request:
author:
---
title: Wrap long Project and Group titles
merge_request: 9301
author:
---
title: Set Auto-Submitted header to mails
merge_request:
author: Semyon Pupkov
---
title: Make Karma output look nicer for CI
merge_request: 9165
author: winniehell
---
title: Adds paginationd and folders view to environments table
merge_request:
author:
---
title: Move babel config for instanbul to karma config
merge_request: 9286
author: winniehell
---
title: Reduced query count for snippet search
merge_request:
author:
ActionMailer::Base.register_interceptor(AdditionalEmailHeadersInterceptor)
......@@ -2,8 +2,20 @@ var path = require('path');
var webpackConfig = require('./webpack.config.js');
var ROOT_PATH = path.resolve(__dirname, '..');
// add coverage instrumentation to babel config
if (webpackConfig && webpackConfig.module && webpackConfig.module.rules) {
var babelConfig = webpackConfig.module.rules.find(function (rule) {
return rule.loader === 'babel-loader';
});
babelConfig.options = babelConfig.options || {};
babelConfig.options.plugins = babelConfig.options.plugins || [];
babelConfig.options.plugins.push('istanbul');
}
// Karma configuration
module.exports = function(config) {
var progressReporter = process.env.CI ? 'mocha' : 'progress';
config.set({
basePath: ROOT_PATH,
browsers: ['PhantomJS'],
......@@ -15,7 +27,7 @@ module.exports = function(config) {
preprocessors: {
'spec/javascripts/**/*.js?(.es6)': ['webpack', 'sourcemap'],
},
reporters: ['progress', 'coverage-istanbul'],
reporters: [progressReporter, 'coverage-istanbul'],
coverageIstanbulReporter: {
reports: ['html', 'text-summary'],
dir: 'coverage-javascript/',
......
......@@ -156,6 +156,10 @@ constraints(ProjectUrlConstrainer.new) do
get :terminal
get '/terminal.ws/authorize', to: 'environments#terminal_websocket_authorize', constraints: { format: nil }
end
collection do
get :folder, path: 'folders/:id'
end
end
resource :cycle_analytics, only: [:show]
......
......@@ -22,6 +22,7 @@ var config = {
commit_pipelines: './commit/pipelines/pipelines_bundle.js',
diff_notes: './diff_notes/diff_notes_bundle.js',
environments: './environments/environments_bundle.js',
environments_folder: './environments/folder/environments_folder_bundle.js',
filtered_search: './filtered_search/filtered_search_bundle.js',
graphs: './graphs/graphs_bundle.js',
issuable: './issuable/issuable_bundle.js',
......@@ -54,7 +55,6 @@ var config = {
exclude: /(node_modules|vendor\/assets)/,
loader: 'babel-loader',
options: {
plugins: IS_PRODUCTION ? [] : ['istanbul'],
presets: [
["es2015", {"modules": false}],
'stage-2'
......
......@@ -535,6 +535,7 @@ deploy_review:
- master
stop_review:
stage: deploy
variables:
GIT_STRATEGY: none
script:
......@@ -555,7 +556,9 @@ when their associated branch is deleted.
When you have an environment that has a stop action defined (typically when
the environment describes a review app), GitLab will automatically trigger a
stop action when the associated branch is deleted.
stop action when the associated branch is deleted. The `stop_review` job must
be in the same `stage` as the `deploy_review` one in order for the environment
to automatically stop.
You can read more in the [`.gitlab-ci.yml` reference][onstop].
......
......@@ -690,6 +690,8 @@ The `stop_review_app` job is **required** to have the following keywords defined
- `when` - [reference](#when)
- `environment:name`
- `environment:action`
- `stage` should be the same as the `review_app` in order for the environment
to stop automatically when the branch is deleted
#### dynamic environments
......
......@@ -3,7 +3,7 @@
The purpose of this guide is to document potential "gotchas" that contributors
might encounter or should avoid during development of GitLab CE and EE.
## Don't `describe` symbols
## Do not `describe` symbols
Consider the following model spec:
......@@ -32,7 +32,7 @@ spec/models/user_spec.rb|6 error| Failure/Error: u = described_class.new NoMeth
Except for the top-level `describe` block, always provide a String argument to
`describe`.
## Don't assert against the absolute value of a sequence-generated attribute
## Do not assert against the absolute value of a sequence-generated attribute
Consider the following factory:
......@@ -121,7 +121,7 @@ describe API::Labels do
end
```
## Don't `rescue Exception`
## Do not `rescue Exception`
See ["Why is it bad style to `rescue Exception => e` in Ruby?"][Exception].
......@@ -130,7 +130,7 @@ Rubocop](https://gitlab.com/gitlab-org/gitlab-ce/blob/8-4-stable/.rubocop.yml#L9
[Exception]: http://stackoverflow.com/q/10048173/223897
## Don't use inline JavaScript in views
## Do not use inline JavaScript in views
Using the inline `:javascript` Haml filters comes with a
performance overhead. Using inline JavaScript is not a good way to structure your code and should be avoided.
......
......@@ -100,7 +100,11 @@ When using the <kbd>Alt</kbd> keystrokes in Windows, use the numeric keypad, not
---
## Terminology
Only use the terms in the tables below.
Only use the terms below.
When using verbs or adjectives:
* If the context clearly refers to the object, use them alone. Example: `Edit` or `Closed`
* If the context isn’t clear enough, use them with the object. Example: `Edit issue` or `Closed issues`
### Projects and Groups
......@@ -112,46 +116,28 @@ Only use the terms in the tables below.
#### Adjectives (states)
| Term |
| ---- |
| Open |
| Closed |
| Deleted |
>**Example:**
Use `5 open issues` and don’t use `5 pending issues`.
| Term | :no_entry_sign: Don’t |
| ---- | --- |
| Open | Don’t use `Pending` or `Created` |
| Closed | Don’t use `Archived` |
| Deleted | Don’t use `Removed` or `Trashed` |
#### Verbs (actions)
| Term | Use | :no_entry_sign: Don’t |
| ---- | --- | --- |
| Add | Add an issue | Don’t use `create` or `new` |
| View | View an open or closed issue ||
| Edit | Edit an open or closed issue | Don’t use `update` |
| Close | Close an open issue ||
| Re-open | Re-open a closed issue | There should never be a need to use `open` as a verb |
| Delete | Delete an open or closed issue ||
#### Add issue
When viewing a list of issues, there is a button that is labeled `Add`. Given the context in the example, it is clearly referring to issues. If the context were not clear enough, the label could be `Add issue`. Clicking the button will bring you to the `Add issue` form. Other add flows should be similar.
![Add issue button](img/copy-form-addissuebutton.png)
The form should be titled `Add issue`. The submit button should be labeled `Submit`. Don’t use `Add`, `Create`, `New`, or `Save changes`. The cancel button should be labeled `Cancel`. Don’t use `Back`.
![Add issue form](img/copy-form-addissueform.png)
#### Edit issue
When in context of an issue, the affordance to edit it is labeled `Edit`. If the context is not clear enough, `Edit issue` could be considered. Other edit flows should be similar.
![Edit issue button](img/copy-form-editissuebutton.png)
The form should be titled `Edit issue`. The submit button should be labeled `Save`. Don’t use `Edit`, `Update`, `Submit`, or `Save changes`. The cancel button should be labeled `Cancel`. Don’t use `Back`.
![Edit issue form](img/copy-form-editissueform.png)
| New | Although it’s not a verb, `New` is a common standard and used for entering the creation mode of a non-existent issue | Don’t use `Create`, `Open`, or `Add` |
| Create | Only to indicate when or who created an issue ||
| Add | Associate an existing issue with an item or a list of items | Don’t use `New` or `Create` |
| View | Open the detail page of an issue | Don’t use `Open` or `See` |
| Edit | Enter the editing mode of an issue | Don’t use `Change`, `Modify` or `Update` |
| Submit | Finalize the *creation* process of an issue | Don’t use `Add`, `Create`, `New`, `Open`, `Save` or `Save changes` |
| Save | Finalize the *editing* process of an issue | Don’t use `Edit`, `Modify`, `Update`, `Submit`, or `Save changes` |
| Cancel | Cancel the *creation* or *editing* process of an issue | Don’t use `Back`, `Close`, or `Discard` |
| Close | Close an open issue | Don’t use `Archive` |
| Re-open | Re-open a closed issue | Don’t use `Open` |
| Delete | Permanently remove an issue from the system | Don’t use `Remove` |
| Remove | Remove the association an issue with an item or a list of items | Don’t use `Delete` |
### Merge requests
......
......@@ -5,7 +5,7 @@ GitLab University is the best place to learn about **Version Control with Git an
It doesn't replace, but accompanies our great [Documentation](https://docs.gitlab.com)
and [Blog Articles](https://about.gitlab.com/blog/).
Would you like to contribute to GitLab University? Then please take a look at our contribution [process](process) for more information.
Would you like to contribute to GitLab University? Then please take a look at our contribution [process](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/PROCESS.md) for more information.
## Gitlab University Curriculum
......
# Services Templates
# Services templates
A GitLab administrator can add a service template that sets a default for each
project. This makes it much easier to configure individual projects.
project. After a service template is enabled, it will be applied to new
projects only and its details will be pre-filled on the project's Service page.
After the template is created, the template details will be pre-filled on a
project's Service page.
## Enable a Service template
## Enable a service template
In GitLab's Admin area, navigate to **Service Templates** and choose the
service template you wish to create.
For example, in the image below you can see Redmine.
## Services for external issue trackers
In the image below you can see how a service template for Redmine would look
like.
![Redmine service template](img/services_templates_redmine_example.png)
---
**NOTE:** For each project, you will still need to configure the issue tracking
For each project, you will still need to configure the issue tracking
URLs by replacing `:issues_tracker_id` in the above screenshot with the ID used
by your external issue tracker. Prior to GitLab v7.8, this ID was configured in
the project settings, and GitLab would automatically update the URL configured
in `gitlab.yml`. This behavior is now deprecated and all issue tracker URLs
must be configured directly within the project's **Services** settings.
must be configured directly within the project's **Integrations** settings.
class AdditionalEmailHeadersInterceptor
def self.delivering_email(message)
message.headers(
'Auto-Submitted' => 'auto-generated',
'X-Auto-Response-Suppress' => 'All'
)
end
end
......@@ -114,7 +114,7 @@ module API
commit = user_project.commit(params[:sha])
not_found! 'Commit' unless commit
notes = Note.where(commit_id: commit.id).order(:created_at)
notes = user_project.notes.where(commit_id: commit.id).order(:created_at)
present paginate(notes), with: Entities::CommitNote
end
......
......@@ -15,14 +15,6 @@ module Gitlab
execute(%W(#{git_bin_path} --git-dir=#{repo_path} bundle create #{bundle_path} --all))
end
def git_unbundle(repo_path:, bundle_path:)
execute(%W(#{git_bin_path} clone --bare #{bundle_path} #{repo_path}))
end
def git_restore_hooks
execute(%W(#{Gitlab.config.gitlab_shell.path}/bin/create-hooks) + repository_storage_paths_args)
end
def mkdir_p(path)
FileUtils.mkdir_p(path, mode: DEFAULT_MODE)
FileUtils.chmod(DEFAULT_MODE, path)
......@@ -56,10 +48,6 @@ module Gitlab
FileUtils.copy_entry(source, destination)
true
end
def repository_storage_paths_args
Gitlab.config.repositories.storages.values
end
end
end
end
......@@ -2,6 +2,7 @@ module Gitlab
module ImportExport
class RepoRestorer
include Gitlab::ImportExport::CommandLineUtil
include Gitlab::ShellAdapter
def initialize(project:, shared:, path_to_bundle:)
@project = project
......@@ -12,29 +13,11 @@ module Gitlab
def restore
return true unless File.exist?(@path_to_bundle)
mkdir_p(path_to_repo)
git_unbundle(repo_path: path_to_repo, bundle_path: @path_to_bundle) && repo_restore_hooks
gitlab_shell.import_repository(@project.repository_storage_path, @project.path_with_namespace, @path_to_bundle)
rescue => e
@shared.error(e)
false
end
private
def path_to_repo
@project.repository.path_to_repo
end
def repo_restore_hooks
return true if wiki?
git_restore_hooks
end
def wiki?
@project.class.name == 'ProjectWiki'
end
end
end
end
......@@ -80,8 +80,10 @@ module Gitlab
# import_repository("/path/to/storage", "gitlab/gitlab-ci", "https://github.com/randx/six.git")
#
def import_repository(storage, name, url)
# Timeout should be less than 900 ideally, to prevent the memory killer
# to silently kill the process without knowing we are timing out here.
output, status = Popen::popen([gitlab_shell_projects_path, 'import-project',
storage, "#{name}.git", url, '900'])
storage, "#{name}.git", url, '800'])
raise Error, output unless status.zero?
true
end
......
......@@ -31,11 +31,11 @@ module Gitlab
private
def snippet_titles
limit_snippets.search(query).order('updated_at DESC')
limit_snippets.search(query).order('updated_at DESC').includes(:author)
end
def snippet_blobs
limit_snippets.search_code(query).order('updated_at DESC')
limit_snippets.search_code(query).order('updated_at DESC').includes(:author)
end
def default_scope
......
......@@ -3,9 +3,12 @@ require 'spec_helper'
describe Projects::EnvironmentsController do
include ApiHelpers
let(:environment) { create(:environment) }
let(:project) { environment.project }
let(:user) { create(:user) }
let(:user) { create(:user) }
let(:project) { create(:empty_project) }
let(:environment) do
create(:environment, name: 'production', project: project)
end
before do
project.team << [user, :master]
......@@ -22,14 +25,58 @@ describe Projects::EnvironmentsController do
end
end
context 'when requesting JSON response' do
it 'responds with correct JSON' do
get :index, environment_params(format: :json)
context 'when requesting JSON response for folders' do
before do
create(:environment, project: project,
name: 'staging/review-1',
state: :available)
create(:environment, project: project,
name: 'staging/review-2',
state: :available)
create(:environment, project: project,
name: 'staging/review-3',
state: :stopped)
end
let(:environments) { json_response['environments'] }
context 'when requesting available environments scope' do
before do
get :index, environment_params(format: :json, scope: :available)
end
it 'responds with a payload describing available environments' do
expect(environments.count).to eq 2
expect(environments.first['name']).to eq 'production'
expect(environments.second['name']).to eq 'staging'
expect(environments.second['size']).to eq 2
expect(environments.second['latest']['name']).to eq 'staging/review-2'
end
first_environment = json_response.first
it 'contains values describing environment scopes sizes' do
expect(json_response['available_count']).to eq 3
expect(json_response['stopped_count']).to eq 1
end
end
expect(first_environment).not_to be_empty
expect(first_environment['name']). to eq environment.name
context 'when requesting stopped environments scope' do
before do
get :index, environment_params(format: :json, scope: :stopped)
end
it 'responds with a payload describing stopped environments' do
expect(environments.count).to eq 1
expect(environments.first['name']).to eq 'staging'
expect(environments.first['size']).to eq 1
expect(environments.first['latest']['name']).to eq 'staging/review-3'
end
it 'contains values describing environment scopes sizes' do
expect(json_response['available_count']).to eq 3
expect(json_response['stopped_count']).to eq 1
end
end
end
end
......
......@@ -1143,15 +1143,15 @@ describe Projects::MergeRequestsController do
end
end
context 'when no special status for MR' do
context 'when MR does not have special state' do
let(:merge_request) { create(:merge_request, source_project: project) }
it 'returns an OK response' do
expect(response).to have_http_status(:ok)
end
it 'sets status to nil' do
expect(assigns(:status)).to be_nil
it 'sets status to success' do
expect(assigns(:status)).to eq(:success)
expect(response).to render_template('merge')
end
end
......
......@@ -52,4 +52,19 @@ describe 'Merge request', :feature, :js do
end
end
end
context 'merge error' do
before do
allow_any_instance_of(Repository).to receive(:merge).and_return(false)
visit namespace_project_merge_request_path(project.namespace, project, merge_request)
click_button 'Accept Merge Request'
wait_for_ajax
end
it 'updates the MR widget' do
page.within('.mr-widget-body') do
expect(page).to have_content('Conflicts detected during merge')
end
end
end
end
......@@ -275,7 +275,7 @@ feature 'Builds', :feature do
let!(:deployment) { create(:deployment, environment: environment, sha: project.commit.id) }
let(:build) { create(:ci_build, :success, environment: environment.name, pipeline: pipeline) }
it 'shows a link to lastest deployment' do
it 'shows a link to latest deployment' do
visit namespace_project_build_path(project.namespace, project, build)
expect(page).to have_link('latest deployment')
......
......@@ -39,6 +39,13 @@ feature 'Download buttons in project main page', feature: true do
expect(page).to have_link "Download '#{build.name}'", href: href
end
scenario 'download links have download attribute' do
expect(page).to have_selector('a', text: 'Download')
page.all('a', text: 'Download').each do |link|
expect(link[:download]).to eq ''
end
end
end
end
end
......@@ -218,6 +218,14 @@ describe 'Pipelines', :feature, :js do
expect(page).to have_link(with_artifacts.name)
end
it 'has download attribute on download links' do
find('.js-pipeline-dropdown-download').click
expect(page).to have_selector('a', text: 'Download')
page.all('.build-artifacts a', text: 'Download').each do |link|
expect(link[:download]).to eq ''
end
end
end
context 'with artifacts expired' do
......
......@@ -40,6 +40,18 @@ describe PageLayoutHelper do
end
end
describe 'favicon' do
it 'defaults to favicon.ico' do
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('production'))
expect(helper.favicon).to eq 'favicon.ico'
end
it 'has blue favicon for development' do
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('development'))
expect(helper.favicon).to eq 'favicon-blue.ico'
end
end
describe 'page_image' do
it 'defaults to the GitLab logo' do
expect(helper.page_image).to end_with 'assets/gitlab_logo.png'
......
require('~/commit/pipelines/pipelines_store');
const PipelinesStore = require('~/commit/pipelines/pipelines_store');
describe('Store', () => {
let store;
beforeEach(() => {
store = new gl.commits.pipelines.PipelinesStore();
store = new PipelinesStore();
});
// unregister intervals and event handlers
......
require('~/environments/components/environment_actions');
const ActionsComponent = require('~/environments/components/environment_actions');
describe('Actions Component', () => {
preloadFixtures('static/environments/element.html.raw');
......@@ -19,7 +19,7 @@ describe('Actions Component', () => {
},
];
const component = new window.gl.environmentsList.ActionsComponent({
const component = new ActionsComponent({
el: document.querySelector('.test-dom-element'),
propsData: {
actions: actionsMock,
......@@ -47,7 +47,7 @@ describe('Actions Component', () => {
},
];
const component = new window.gl.environmentsList.ActionsComponent({
const component = new ActionsComponent({
el: document.querySelector('.test-dom-element'),
propsData: {
actions: actionsMock,
......
require('~/environments/components/environment_external_url');
const ExternalUrlComponent = require('~/environments/components/environment_external_url');
describe('External URL Component', () => {
preloadFixtures('static/environments/element.html.raw');
......@@ -8,7 +8,7 @@ describe('External URL Component', () => {
it('should link to the provided externalUrl prop', () => {
const externalURL = 'https://gitlab.com';
const component = new window.gl.environmentsList.ExternalUrlComponent({
const component = new ExternalUrlComponent({
el: document.querySelector('.test-dom-element'),
propsData: {
externalUrl: externalURL,
......
window.timeago = require('timeago.js');
require('~/environments/components/environment_item');
const EnvironmentItem = require('~/environments/components/environment_item');
describe('Environment item', () => {
preloadFixtures('static/environments/table.html.raw');
......@@ -14,33 +14,16 @@ describe('Environment item', () => {
beforeEach(() => {
mockItem = {
name: 'review',
children: [
{
name: 'review-app',
id: 1,
state: 'available',
external_url: '',
last_deployment: {},
created_at: '2016-11-07T11:11:16.525Z',
updated_at: '2016-11-10T15:55:58.778Z',
},
{
name: 'production',
id: 2,
state: 'available',
external_url: '',
last_deployment: {},
created_at: '2016-11-07T11:11:16.525Z',
updated_at: '2016-11-10T15:55:58.778Z',
},
],
folderName: 'review',
size: 3,
isFolder: true,
environment_path: 'url',
};
component = new window.gl.environmentsList.EnvironmentItem({
component = new EnvironmentItem({
el: document.querySelector('tr#environment-row'),
propsData: {
model: mockItem,
toggleRow: () => {},
canCreateDeployment: false,
canReadEnvironment: true,
},
......@@ -53,7 +36,7 @@ describe('Environment item', () => {
});
it('Should render the number of children in a badge', () => {
expect(component.$el.querySelector('.folder-name .badge').textContent).toContain(mockItem.children.length);
expect(component.$el.querySelector('.folder-name .badge').textContent).toContain(mockItem.size);
});
});
......@@ -63,8 +46,8 @@ describe('Environment item', () => {
beforeEach(() => {
environment = {
id: 31,
name: 'production',
size: 1,
state: 'stopped',
external_url: 'http://external.com',
environment_type: null,
......@@ -125,11 +108,10 @@ describe('Environment item', () => {
updated_at: '2016-11-10T15:55:58.778Z',
};
component = new window.gl.environmentsList.EnvironmentItem({
component = new EnvironmentItem({
el: document.querySelector('tr#environment-row'),
propsData: {
model: environment,
toggleRow: () => {},
canCreateDeployment: true,
canReadEnvironment: true,
},
......
require('~/environments/components/environment_rollback');
const RollbackComponent = require('~/environments/components/environment_rollback');
describe('Rollback Component', () => {
preloadFixtures('static/environments/element.html.raw');
......@@ -10,7 +10,7 @@ describe('Rollback Component', () => {
});
it('Should link to the provided retryUrl', () => {
const component = new window.gl.environmentsList.RollbackComponent({
const component = new RollbackComponent({
el: document.querySelector('.test-dom-element'),
propsData: {
retryUrl: retryURL,
......@@ -22,7 +22,7 @@ describe('Rollback Component', () => {
});
it('Should render Re-deploy label when isLastDeployment is true', () => {
const component = new window.gl.environmentsList.RollbackComponent({
const component = new RollbackComponent({
el: document.querySelector('.test-dom-element'),
propsData: {
retryUrl: retryURL,
......@@ -34,7 +34,7 @@ describe('Rollback Component', () => {
});
it('Should render Rollback label when isLastDeployment is false', () => {
const component = new window.gl.environmentsList.RollbackComponent({
const component = new RollbackComponent({
el: document.querySelector('.test-dom-element'),
propsData: {
retryUrl: retryURL,
......
/* global Vue, environment */
const Vue = require('vue');
require('~/flash');
require('~/environments/stores/environments_store');
require('~/environments/components/environment');
require('./mock_data');
const EnvironmentsComponent = require('~/environments/components/environment');
const { environment } = require('./mock_data');
describe('Environment', () => {
preloadFixtures('static/environments/environments.html.raw');
......@@ -33,11 +31,8 @@ describe('Environment', () => {
});
it('should render the empty state', (done) => {
component = new gl.environmentsList.EnvironmentsComponent({
component = new EnvironmentsComponent({
el: document.querySelector('#environments-list-view'),
propsData: {
store: gl.environmentsList.EnvironmentsStore.create(),
},
});
setTimeout(() => {
......@@ -54,15 +49,30 @@ describe('Environment', () => {
});
});
describe('with environments', () => {
describe('with paginated environments', () => {
const environmentsResponseInterceptor = (request, next) => {
next(request.respondWith(JSON.stringify([environment]), {
next(request.respondWith(JSON.stringify({
environments: [environment],
stopped_count: 1,
available_count: 0,
}), {
status: 200,
headers: {
'X-nExt-pAge': '2',
'x-page': '1',
'X-Per-Page': '1',
'X-Prev-Page': '',
'X-TOTAL': '37',
'X-Total-Pages': '2',
},
}));
};
beforeEach(() => {
Vue.http.interceptors.push(environmentsResponseInterceptor);
component = new EnvironmentsComponent({
el: document.querySelector('#environments-list-view'),
});
});
afterEach(() => {
......@@ -72,13 +82,6 @@ describe('Environment', () => {
});
it('should render a table with environments', (done) => {
component = new gl.environmentsList.EnvironmentsComponent({
el: document.querySelector('#environments-list-view'),
propsData: {
store: gl.environmentsList.EnvironmentsStore.create(),
},
});
setTimeout(() => {
expect(
component.$el.querySelectorAll('table tbody tr').length,
......@@ -86,6 +89,59 @@ describe('Environment', () => {
done();
}, 0);
});
describe('pagination', () => {
it('should render pagination', (done) => {
setTimeout(() => {
expect(
component.$el.querySelectorAll('.gl-pagination li').length,
).toEqual(5);
done();
}, 0);
});
it('should update url when no search params are present', (done) => {
spyOn(gl.utils, 'visitUrl');
setTimeout(() => {
component.$el.querySelector('.gl-pagination li:nth-child(5) a').click();
expect(gl.utils.visitUrl).toHaveBeenCalledWith('?page=2');
done();
}, 0);
});
it('should update url when page is already present', (done) => {
spyOn(gl.utils, 'visitUrl');
window.history.pushState({}, null, '?page=1');
setTimeout(() => {
component.$el.querySelector('.gl-pagination li:nth-child(5) a').click();
expect(gl.utils.visitUrl).toHaveBeenCalledWith('?page=2');
done();
}, 0);
});
it('should update url when page and scope are already present', (done) => {
spyOn(gl.utils, 'visitUrl');
window.history.pushState({}, null, '?scope=all&page=1');
setTimeout(() => {
component.$el.querySelector('.gl-pagination li:nth-child(5) a').click();
expect(gl.utils.visitUrl).toHaveBeenCalledWith('?scope=all&page=2');
done();
}, 0);
});
it('should update url when page and scope are already present and page is first param', (done) => {
spyOn(gl.utils, 'visitUrl');
window.history.pushState({}, null, '?page=1&scope=all');
setTimeout(() => {
component.$el.querySelector('.gl-pagination li:nth-child(5) a').click();
expect(gl.utils.visitUrl).toHaveBeenCalledWith('?page=2&scope=all');
done();
}, 0);
});
});
});
});
......@@ -107,11 +163,8 @@ describe('Environment', () => {
});
it('should render empty state', (done) => {
component = new gl.environmentsList.EnvironmentsComponent({
component = new EnvironmentsComponent({
el: document.querySelector('#environments-list-view'),
propsData: {
store: gl.environmentsList.EnvironmentsStore.create(),
},
});
setTimeout(() => {
......
require('~/environments/components/environment_stop');
const StopComponent = require('~/environments/components/environment_stop');
describe('Stop Component', () => {
preloadFixtures('static/environments/element.html.raw');
......@@ -10,7 +10,7 @@ describe('Stop Component', () => {
loadFixtures('static/environments/element.html.raw');
stopURL = '/stop';
component = new window.gl.environmentsList.StopComponent({
component = new StopComponent({
el: document.querySelector('.test-dom-element'),
propsData: {
stopUrl: stopURL,
......
const EnvironmentTable = require('~/environments/components/environments_table');
describe('Environment item', () => {
preloadFixtures('static/environments/element.html.raw');
beforeEach(() => {
loadFixtures('static/environments/element.html.raw');
});
it('Should render a table', () => {
const mockItem = {
name: 'review',
size: 3,
isFolder: true,
latest: {
environment_path: 'url',
},
};
const component = new EnvironmentTable({
el: document.querySelector('.test-dom-element'),
propsData: {
environments: [{ mockItem }],
canCreateDeployment: false,
canReadEnvironment: true,
},
});
expect(component.$el.tagName).toEqual('TABLE');
});
});
/* global environmentsList */
require('~/environments/stores/environments_store');
require('./mock_data');
const Store = require('~/environments/stores/environments_store');
const { environmentsList, serverData } = require('./mock_data');
(() => {
describe('Store', () => {
let store;
beforeEach(() => {
gl.environmentsList.EnvironmentsStore.create();
store = new Store();
});
it('should start with a blank state', () => {
expect(gl.environmentsList.EnvironmentsStore.state.environments.length).toBe(0);
expect(gl.environmentsList.EnvironmentsStore.state.stoppedCounter).toBe(0);
expect(gl.environmentsList.EnvironmentsStore.state.availableCounter).toBe(0);
expect(store.state.environments.length).toEqual(0);
expect(store.state.stoppedCounter).toEqual(0);
expect(store.state.availableCounter).toEqual(0);
expect(store.state.paginationInformation).toEqual({});
});
describe('store environments', () => {
beforeEach(() => {
gl.environmentsList.EnvironmentsStore.storeEnvironments(environmentsList);
});
it('should count stopped environments and save the count in the state', () => {
expect(gl.environmentsList.EnvironmentsStore.state.stoppedCounter).toBe(1);
});
it('should count available environments and save the count in the state', () => {
expect(gl.environmentsList.EnvironmentsStore.state.availableCounter).toBe(3);
});
it('should store environments with same environment_type as sibilings', () => {
expect(gl.environmentsList.EnvironmentsStore.state.environments.length).toBe(3);
const parentFolder = gl.environmentsList.EnvironmentsStore.state.environments
.filter(env => env.children && env.children.length > 0);
expect(parentFolder[0].children.length).toBe(2);
expect(parentFolder[0].children[0].environment_type).toBe('review');
expect(parentFolder[0].children[1].environment_type).toBe('review');
expect(parentFolder[0].children[0].name).toBe('test-environment');
expect(parentFolder[0].children[1].name).toBe('test-environment-1');
});
it('should sort the environments alphabetically', () => {
const { environments } = gl.environmentsList.EnvironmentsStore.state;
expect(environments[0].name).toBe('production');
expect(environments[1].name).toBe('review');
expect(environments[1].children[0].name).toBe('test-environment');
expect(environments[1].children[1].name).toBe('test-environment-1');
expect(environments[2].name).toBe('review_app');
});
it('should store environments', () => {
store.storeEnvironments(serverData);
expect(store.state.environments.length).toEqual(serverData.length);
expect(store.state.environments[0]).toEqual(environmentsList[0]);
});
describe('toggleFolder', () => {
beforeEach(() => {
gl.environmentsList.EnvironmentsStore.storeEnvironments(environmentsList);
});
it('should toggle the open property for the given environment', () => {
gl.environmentsList.EnvironmentsStore.toggleFolder('review');
it('should store available count', () => {
store.storeAvailableCount(2);
expect(store.state.availableCounter).toEqual(2);
});
const { environments } = gl.environmentsList.EnvironmentsStore.state;
const environment = environments.filter(env => env['vue-isChildren'] === true && env.name === 'review');
it('should store stopped count', () => {
store.storeStoppedCount(2);
expect(store.state.stoppedCounter).toEqual(2);
});
expect(environment[0].isOpen).toBe(true);
});
it('should store pagination information', () => {
const pagination = {
'X-nExt-pAge': '2',
'X-page': '1',
'X-Per-Page': '1',
'X-Prev-Page': '2',
'X-TOTAL': '37',
'X-Total-Pages': '2',
};
const expectedResult = {
perPage: 1,
page: 1,
total: 37,
totalPages: 2,
nextPage: 2,
previousPage: 2,
};
store.setPagination(pagination);
expect(store.state.paginationInformation).toEqual(expectedResult);
});
});
})();
const Vue = require('vue');
require('~/flash');
const EnvironmentsFolderViewComponent = require('~/environments/folder/environments_folder_view');
const { environmentsList } = require('../mock_data');
describe('Environments Folder View', () => {
preloadFixtures('static/environments/environments_folder_view.html.raw');
beforeEach(() => {
loadFixtures('static/environments/environments_folder_view.html.raw');
window.history.pushState({}, null, 'environments/folders/build');
});
let component;
describe('successfull request', () => {
const environmentsResponseInterceptor = (request, next) => {
next(request.respondWith(JSON.stringify({
environments: environmentsList,
stopped_count: 1,
available_count: 0,
}), {
status: 200,
headers: {
'X-nExt-pAge': '2',
'x-page': '1',
'X-Per-Page': '1',
'X-Prev-Page': '',
'X-TOTAL': '37',
'X-Total-Pages': '2',
},
}));
};
beforeEach(() => {
Vue.http.interceptors.push(environmentsResponseInterceptor);
component = new EnvironmentsFolderViewComponent({
el: document.querySelector('#environments-folder-list-view'),
});
});
afterEach(() => {
Vue.http.interceptors = _.without(
Vue.http.interceptors, environmentsResponseInterceptor,
);
});
it('should render a table with environments', (done) => {
setTimeout(() => {
expect(
component.$el.querySelectorAll('table tbody tr').length,
).toEqual(2);
done();
}, 0);
});
it('should render available tab with count', (done) => {
setTimeout(() => {
expect(
component.$el.querySelector('.js-available-environments-folder-tab').textContent,
).toContain('Available');
expect(
component.$el.querySelector('.js-available-environments-folder-tab .js-available-environments-count').textContent,
).toContain('0');
done();
}, 0);
});
it('should render stopped tab with count', (done) => {
setTimeout(() => {
expect(
component.$el.querySelector('.js-stopped-environments-folder-tab').textContent,
).toContain('Stopped');
expect(
component.$el.querySelector('.js-stopped-environments-folder-tab .js-stopped-environments-count').textContent,
).toContain('1');
done();
}, 0);
});
it('should render parent folder name', (done) => {
setTimeout(() => {
expect(
component.$el.querySelector('.js-folder-name').textContent,
).toContain('Environments / build');
done();
}, 0);
});
describe('pagination', () => {
it('should render pagination', (done) => {
setTimeout(() => {
expect(
component.$el.querySelectorAll('.gl-pagination li').length,
).toEqual(5);
done();
}, 0);
});
it('should update url when no search params are present', (done) => {
spyOn(gl.utils, 'visitUrl');
setTimeout(() => {
component.$el.querySelector('.gl-pagination li:nth-child(5) a').click();
expect(gl.utils.visitUrl).toHaveBeenCalledWith('?page=2');
done();
}, 0);
});
it('should update url when page is already present', (done) => {
spyOn(gl.utils, 'visitUrl');
window.history.pushState({}, null, '?page=1');
setTimeout(() => {
component.$el.querySelector('.gl-pagination li:nth-child(5) a').click();
expect(gl.utils.visitUrl).toHaveBeenCalledWith('?page=2');
done();
}, 0);
});
it('should update url when page and scope are already present', (done) => {
spyOn(gl.utils, 'visitUrl');
window.history.pushState({}, null, '?scope=all&page=1');
setTimeout(() => {
component.$el.querySelector('.gl-pagination li:nth-child(5) a').click();
expect(gl.utils.visitUrl).toHaveBeenCalledWith('?scope=all&page=2');
done();
}, 0);
});
it('should update url when page and scope are already present and page is first param', (done) => {
spyOn(gl.utils, 'visitUrl');
window.history.pushState({}, null, '?page=1&scope=all');
setTimeout(() => {
component.$el.querySelector('.gl-pagination li:nth-child(5) a').click();
expect(gl.utils.visitUrl).toHaveBeenCalledWith('?page=2&scope=all');
done();
}, 0);
});
});
});
describe('unsuccessfull request', () => {
const environmentsErrorResponseInterceptor = (request, next) => {
next(request.respondWith(JSON.stringify([]), {
status: 500,
}));
};
beforeEach(() => {
Vue.http.interceptors.push(environmentsErrorResponseInterceptor);
});
afterEach(() => {
Vue.http.interceptors = _.without(
Vue.http.interceptors, environmentsErrorResponseInterceptor,
);
});
it('should not render a table', (done) => {
component = new EnvironmentsFolderViewComponent({
el: document.querySelector('#environments-folder-list-view'),
});
setTimeout(() => {
expect(
component.$el.querySelector('table'),
).toBe(null);
done();
}, 0);
});
it('should render available tab with count 0', (done) => {
setTimeout(() => {
expect(
component.$el.querySelector('.js-available-environments-folder-tab').textContent,
).toContain('Available');
expect(
component.$el.querySelector('.js-available-environments-folder-tab .js-available-environments-count').textContent,
).toContain('0');
done();
}, 0);
});
it('should render stopped tab with count 0', (done) => {
setTimeout(() => {
expect(
component.$el.querySelector('.js-stopped-environments-folder-tab').textContent,
).toContain('Stopped');
expect(
component.$el.querySelector('.js-stopped-environments-folder-tab .js-stopped-environments-count').textContent,
).toContain('0');
done();
}, 0);
});
});
});
const environmentsList = [
{
id: 31,
name: 'production',
name: 'DEV',
size: 1,
id: 7,
state: 'available',
external_url: 'https://www.gitlab.com',
environment_type: null,
last_deployment: {
id: 64,
iid: 5,
sha: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
ref: {
name: 'master',
ref_url: 'http://localhost:3000/root/ci-folders/tree/master',
},
tag: false,
'last?': true,
user: {
name: 'Administrator',
username: 'root',
id: 1,
state: 'active',
avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
web_url: 'http://localhost:3000/root',
},
commit: {
id: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
short_id: '500aabcb',
title: 'Update .gitlab-ci.yml',
author_name: 'Administrator',
author_email: 'admin@example.com',
created_at: '2016-11-07T18:28:13.000+00:00',
message: 'Update .gitlab-ci.yml',
author: {
name: 'Administrator',
username: 'root',
id: 1,
state: 'active',
avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
web_url: 'http://localhost:3000/root',
},
commit_path: '/root/ci-folders/tree/500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
},
deployable: {
id: 1278,
name: 'build',
build_path: '/root/ci-folders/builds/1278',
retry_path: '/root/ci-folders/builds/1278/retry',
},
manual_actions: [],
},
'stop_action?': true,
environment_path: '/root/ci-folders/environments/31',
created_at: '2016-11-07T11:11:16.525Z',
updated_at: '2016-11-07T11:11:16.525Z',
},
{
id: 32,
name: 'review_app',
state: 'stopped',
external_url: 'https://www.gitlab.com',
external_url: null,
environment_type: null,
last_deployment: {
id: 64,
iid: 5,
sha: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
ref: {
name: 'master',
ref_url: 'http://localhost:3000/root/ci-folders/tree/master',
},
tag: false,
'last?': true,
user: {
name: 'Administrator',
username: 'root',
id: 1,
state: 'active',
avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
web_url: 'http://localhost:3000/root',
},
commit: {
id: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
short_id: '500aabcb',
title: 'Update .gitlab-ci.yml',
author_name: 'Administrator',
author_email: 'admin@example.com',
created_at: '2016-11-07T18:28:13.000+00:00',
message: 'Update .gitlab-ci.yml',
author: {
name: 'Administrator',
username: 'root',
id: 1,
state: 'active',
avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
web_url: 'http://localhost:3000/root',
},
commit_path: '/root/ci-folders/tree/500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
},
deployable: {
id: 1278,
name: 'build',
build_path: '/root/ci-folders/builds/1278',
retry_path: '/root/ci-folders/builds/1278/retry',
},
manual_actions: [],
},
last_deployment: null,
'stop_action?': false,
environment_path: '/root/ci-folders/environments/31',
created_at: '2016-11-07T11:11:16.525Z',
updated_at: '2016-11-07T11:11:16.525Z',
environment_path: '/root/review-app/environments/7',
stop_path: '/root/review-app/environments/7/stop',
created_at: '2017-01-31T10:53:46.894Z',
updated_at: '2017-01-31T10:53:46.894Z',
},
{
id: 33,
name: 'test-environment',
folderName: 'build',
size: 5,
id: 12,
name: 'build/update-README',
state: 'available',
environment_type: 'review',
external_url: null,
environment_type: 'build',
last_deployment: null,
'stop_action?': true,
environment_path: '/root/ci-folders/environments/31',
created_at: '2016-11-07T11:11:16.525Z',
updated_at: '2016-11-07T11:11:16.525Z',
'stop_action?': false,
environment_path: '/root/review-app/environments/12',
stop_path: '/root/review-app/environments/12/stop',
created_at: '2017-02-01T19:42:18.400Z',
updated_at: '2017-02-01T19:42:18.400Z',
},
];
const serverData = [
{
id: 34,
name: 'test-environment-1',
state: 'available',
environment_type: 'review',
last_deployment: null,
'stop_action?': true,
environment_path: '/root/ci-folders/environments/31',
created_at: '2016-11-07T11:11:16.525Z',
updated_at: '2016-11-07T11:11:16.525Z',
name: 'DEV',
size: 1,
latest: {
id: 7,
name: 'DEV',
state: 'available',
external_url: null,
environment_type: null,
last_deployment: null,
'stop_action?': false,
environment_path: '/root/review-app/environments/7',
stop_path: '/root/review-app/environments/7/stop',
created_at: '2017-01-31T10:53:46.894Z',
updated_at: '2017-01-31T10:53:46.894Z',
},
},
{
name: 'build',
size: 5,
latest: {
id: 12,
name: 'build/update-README',
state: 'available',
external_url: null,
environment_type: 'build',
last_deployment: null,
'stop_action?': false,
environment_path: '/root/review-app/environments/12',
stop_path: '/root/review-app/environments/12/stop',
created_at: '2017-02-01T19:42:18.400Z',
updated_at: '2017-02-01T19:42:18.400Z',
},
},
];
window.environmentsList = environmentsList;
const environment = {
id: 4,
name: 'production',
state: 'available',
external_url: 'http://production.',
environment_type: null,
last_deployment: {},
'stop_action?': false,
environment_path: '/root/review-app/environments/4',
stop_path: '/root/review-app/environments/4/stop',
created_at: '2016-12-16T11:51:04.690Z',
updated_at: '2016-12-16T12:04:51.133Z',
name: 'DEV',
size: 1,
latest: {
id: 7,
name: 'DEV',
state: 'available',
external_url: null,
environment_type: null,
last_deployment: null,
'stop_action?': false,
environment_path: '/root/review-app/environments/7',
stop_path: '/root/review-app/environments/7/stop',
created_at: '2017-01-31T10:53:46.894Z',
updated_at: '2017-01-31T10:53:46.894Z',
},
};
window.environment = environment;
module.exports = {
environmentsList,
environment,
serverData,
};
%div
#environments-folder-list-view{ data: { "can-create-deployment" => "true",
"can-read-environment" => "true",
"css-class" => "",
"commit-icon-svg" => custom_icon("icon_commit"),
"terminal-icon-svg" => custom_icon("icon_terminal"),
"play-icon-svg" => custom_icon("icon_play") } }
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