Commit 18be60e9 authored by Fatih Acet's avatar Fatih Acet Committed by Alejandro Rodríguez

Merge branch '22539-display-folders' into 'master'

Resolve "Display "folders" for environments"

## What does this MR do?
Adds the ability to show the grouped environments inside "folders".
Adds several reusable vue components in order to accomplish the recursive tree data structure presented.

For the individual components, Jasmine tests were added.
For the ones that depend of an API response, rspec tests are used.


## Screenshots (if relevant)
![Screen_Shot_2016-11-16_at_02.00.13](/uploads/1278012c8639b999b53f080728d283e1/Screen_Shot_2016-11-16_at_02.00.13.png)
![Screen_Shot_2016-11-16_at_02.00.25](/uploads/a3d65416ddb553e1b8f0f4c8897a75dc/Screen_Shot_2016-11-16_at_02.00.25.png)
![Screen_Shot_2016-10-17_at_16.08.50](/uploads/af63efe1d2cbd5fc069408622ef4b607/Screen_Shot_2016-10-17_at_16.08.50.png)


![environments](/uploads/b5a1801766d82ab176fc60f96b6968cb/environments.gif)
## Does this MR meet the acceptance criteria?

- [x] [CHANGELOG](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CHANGELOG.md) entry added
- [ ] [Documentation created/updated](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/development/doc_styleguide.md)
- [ ] API support added
- Tests
  - [x] Added for this feature/bug
  - [ ] All builds are passing
- [x] Conform by the [merge request performance guides](http://docs.gitlab.com/ce/development/merge_request_performance_guidelines.html)
- [x] Conform by the [style guides](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#style-guides)
- [x] Branch has no merge conflicts with `master` (if it does - rebase it please)
- [x] [Squashed related commits together](https://git-scm.com/book/en/Git-Tools-Rewriting-History#Squashing-Commits)

## What are the relevant issue numbers?
Closes #22539

See merge request !7015
parent f9b9ad66
//= require vue
//= require vue-resource
//= require_tree ../services/
//= require ./environment_item
/* globals Vue, EnvironmentsService */
/* eslint-disable no-param-reassign */
(() => { // eslint-disable-line
window.gl = window.gl || {};
/**
* 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 `filterEnvironmnetsByState`
* 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.
*/
const filterState = state => environment => environment.state === state && environment;
/**
* Given the filter function and the array of environments will return only
* the environments that match the state provided to the filter function.
*
* @param {Function} fn
* @param {Array} array
* @return {Array}
*/
const filterEnvironmnetsByState = (fn, arr) => arr.map((item) => {
if (item.children) {
const filteredChildren = filterEnvironmnetsByState(fn, item.children).filter(Boolean);
if (filteredChildren.length) {
item.children = filteredChildren;
return item;
}
}
return fn(item);
}).filter(Boolean);
window.gl.environmentsList.EnvironmentsComponent = Vue.component('environment-component', {
props: {
store: {
type: Object,
required: true,
default: () => ({}),
},
},
components: {
'environment-item': window.gl.environmentsList.EnvironmentItem,
},
data() {
const environmentsData = document.querySelector('#environments-list-view').dataset;
return {
state: this.store.state,
visibility: 'available',
isLoading: false,
cssContainerClass: environmentsData.cssClass,
endpoint: environmentsData.environmentsDataEndpoint,
canCreateDeployment: environmentsData.canCreateDeployment,
canReadEnvironment: environmentsData.canReadEnvironment,
canCreateEnvironment: environmentsData.canCreateEnvironment,
projectEnvironmentsPath: environmentsData.projectEnvironmentsPath,
projectStoppedEnvironmentsPath: environmentsData.projectStoppedEnvironmentsPath,
newEnvironmentPath: environmentsData.newEnvironmentPath,
helpPagePath: environmentsData.helpPagePath,
};
},
computed: {
filteredEnvironments() {
return filterEnvironmnetsByState(filterState(this.visibility), this.state.environments);
},
scope() {
return this.$options.getQueryParameter('scope');
},
canReadEnvironmentParsed() {
return this.$options.convertPermissionToBoolean(this.canReadEnvironment);
},
canCreateDeploymentParsed() {
return this.$options.convertPermissionToBoolean(this.canCreateDeployment);
},
canCreateEnvironmentParsed() {
return this.$options.convertPermissionToBoolean(this.canCreateEnvironment);
},
},
/**
* Fetches all the environmnets and stores them.
* Toggles loading property.
*/
created() {
gl.environmentsService = new EnvironmentsService(this.endpoint);
const scope = this.$options.getQueryParameter('scope');
if (scope) {
this.visibility = scope;
}
this.isLoading = true;
return gl.environmentsService.all()
.then(resp => resp.json())
.then((json) => {
this.store.storeEnvironments(json);
this.isLoading = false;
});
},
/**
* 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];
},
/**
* Converts permission provided as strings to booleans.
* @param {String} string
* @returns {Boolean}
*/
convertPermissionToBoolean(string) {
return string === 'true';
},
methods: {
toggleRow(model) {
return this.store.toggleFolder(model.name);
},
},
template: `
<div :class="cssContainerClass">
<div class="top-area">
<ul v-if="!isLoading" class="nav-links">
<li v-bind:class="{ 'active': scope === undefined }">
<a :href="projectEnvironmentsPath">
Available
<span
class="badge js-available-environments-count"
v-html="state.availableCounter"></span>
</a>
</li>
<li v-bind:class="{ 'active' : scope === 'stopped' }">
<a :href="projectStoppedEnvironmentsPath">
Stopped
<span
class="badge js-stopped-environments-count"
v-html="state.stoppedCounter"></span>
</a>
</li>
</ul>
<div v-if="canCreateEnvironmentParsed && !isLoading" class="nav-controls">
<a :href="newEnvironmentPath" class="btn btn-create">
New environment
</a>
</div>
</div>
<div class="environments-container">
<div class="environments-list-loading text-center" v-if="isLoading">
<i class="fa fa-spinner spin"></i>
</div>
<div
class="blank-state blank-state-no-icon"
v-if="!isLoading && state.environments.length === 0">
<h2 class="blank-state-title">
You don't have any environments right now.
</h2>
<p class="blank-state-text">
Environments are places where code gets deployed, such as staging or production.
<br />
<a :href="helpPagePath">
Read more about environments
</a>
</p>
<a
v-if="canCreateEnvironmentParsed"
:href="newEnvironmentPath"
class="btn btn-create">
New Environment
</a>
</div>
<div
class="table-holder"
v-if="!isLoading && state.environments.length > 0">
<table class="table ci-table environments">
<thead>
<tr>
<th>Environment</th>
<th>Last deployment</th>
<th>Build</th>
<th>Commit</th>
<th></th>
<th class="hidden-xs"></th>
</tr>
</thead>
<tbody>
<template v-for="model in filteredEnvironments"
v-bind:model="model">
<tr
is="environment-item"
:model="model"
:toggleRow="toggleRow.bind(model)"
:can-create-deployment="canCreateDeploymentParsed"
:can-read-environment="canReadEnvironmentParsed"></tr>
<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)">
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
`,
});
})();
/*= require vue */
/* global Vue */
(() => {
window.gl = window.gl || {};
window.gl.environmentsList = window.gl.environmentsList || {};
window.gl.environmentsList.ActionsComponent = Vue.component('actions-component', {
props: {
actions: {
type: Array,
required: false,
default: () => [],
},
},
/**
* Appends the svg icon that were render in the index page.
* In order to reuse the svg instead of copy and paste in this template
* we need to render it outside this component using =custom_icon partial.
*
* TODO: Remove this when webpack is merged.
*
*/
mounted() {
const playIcon = document.querySelector('.play-icon-svg.hidden svg');
const dropdownContainer = this.$el.querySelector('.dropdown-play-icon-container');
const actionContainers = this.$el.querySelectorAll('.action-play-icon-container');
// Phantomjs does not have support to iterate a nodelist.
const actionsArray = [].slice.call(actionContainers);
if (playIcon && actionsArray && dropdownContainer) {
dropdownContainer.appendChild(playIcon.cloneNode(true));
actionsArray.forEach((element) => {
element.appendChild(playIcon.cloneNode(true));
});
}
},
template: `
<div class="inline">
<div class="dropdown">
<a class="dropdown-new btn btn-default" data-toggle="dropdown">
<span class="dropdown-play-icon-container">
</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="action-play-icon-container">
</span>
<span v-html="action.name"></span>
</a>
</li>
</ul>
</div>
</div>
`,
});
})();
/*= require vue */
/* global Vue */
(() => {
window.gl = window.gl || {};
window.gl.environmentsList = window.gl.environmentsList || {};
window.gl.environmentsList.ExternalUrlComponent = Vue.component('external-url-component', {
props: {
external_url: {
type: String,
default: '',
},
},
template: `
<a class="btn external_url" :href="external_url" target="_blank">
<i class="fa fa-external-link"></i>
</a>
`,
});
})();
/*= require vue */
/* global Vue */
(() => {
window.gl = window.gl || {};
window.gl.environmentsList = window.gl.environmentsList || {};
window.gl.environmentsList.RollbackComponent = Vue.component('rollback-component', {
props: {
retry_url: {
type: String,
default: '',
},
is_last_deployment: {
type: Boolean,
default: true,
},
},
template: `
<a class="btn" :href="retry_url" data-method="post" rel="nofollow">
<span v-if="is_last_deployment">
Re-deploy
</span>
<span v-else>
Rollback
</span>
</a>
`,
});
})();
/*= require vue */
/* global Vue */
(() => {
window.gl = window.gl || {};
window.gl.environmentsList = window.gl.environmentsList || {};
window.gl.environmentsList.StopComponent = Vue.component('stop-component', {
props: {
stop_url: {
type: String,
default: '',
},
},
template: `
<a
class="btn stop-env-link"
:href="stop_url"
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>
`,
});
})();
//= require vue
//= require_tree ./stores/
//= require ./components/environment
//= require ./vue_resource_interceptor
$(() => {
window.gl = window.gl || {};
if (window.gl.EnvironmentsListApp) {
window.gl.EnvironmentsListApp.$destroy(true);
}
const Store = window.gl.environmentsList.EnvironmentsStore;
window.gl.EnvironmentsListApp = new window.gl.environmentsList.EnvironmentsComponent({
el: document.querySelector('#environments-list-view'),
propsData: {
store: Store.create(),
},
});
});
/* globals Vue */
/* eslint-disable no-unused-vars, no-param-reassign */
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();
});
}
all() {
return this.environments.get();
}
}
/* 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;
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.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;
}, []).sort(this.sortByName);
this.state.environments = environmentsTree;
return environmentsTree;
},
/**
* 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
},
};
})();
/* global Vue */
Vue.http.interceptors.push((request, next) => {
Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1;
next((response) => {
if (typeof response.data === 'string') {
response.data = JSON.parse(response.data); // eslint-disable-line
}
Vue.activeResources--; // eslint-disable-line
});
});
...@@ -112,6 +112,9 @@ ...@@ -112,6 +112,9 @@
gl.text.removeListeners = function(form) { gl.text.removeListeners = function(form) {
return $('.js-md', form).off(); return $('.js-md', form).off();
}; };
gl.text.humanize = function(string) {
return string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
}
return gl.text.truncate = function(string, maxLength) { return gl.text.truncate = function(string, maxLength) {
return string.substr(0, (maxLength - 3)) + '...'; return string.substr(0, (maxLength - 3)) + '...';
}; };
......
/*= require vue */
/* global Vue */
(() => {
window.gl = window.gl || {};
window.gl.CommitComponent = Vue.component('commit-component', {
props: {
/**
* Indicates the existance of a tag.
* Used to render the correct icon, if true will render `fa-tag` icon,
* if false will render `fa-code-fork` icon.
*/
tag: {
type: Boolean,
required: false,
default: false,
},
/**
* If provided is used to render the branch name and url.
* Should contain the following properties:
* name
* ref_url
*/
ref: {
type: Object,
required: false,
default: () => ({}),
},
/**
* Used to link to the commit sha.
*/
commit_url: {
type: String,
required: false,
default: '',
},
/**
* Used to show the commit short_sha that links to the commit url.
*/
short_sha: {
type: String,
required: false,
default: '',
},
/**
* If provided shows the commit tile.
*/
title: {
type: String,
required: false,
default: '',
},
/**
* If provided renders information about the author of the commit.
* When provided should include:
* `avatar_url` to render the avatar icon
* `web_url` to link to user profile
* `username` to render alt and title tags
*/
author: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
/**
* Used to verify if all the properties needed to render the commit
* ref section were provided.
*
* TODO: Improve this! Use lodash _.has when we have it.
*
* @returns {Boolean}
*/
hasRef() {
return this.ref && this.ref.name && this.ref.ref_url;
},
/**
* Used to verify if all the properties needed to render the commit
* author section were provided.
*
* TODO: Improve this! Use lodash _.has when we have it.
*
* @returns {Boolean}
*/
hasAuthor() {
return this.author &&
this.author.avatar_url &&
this.author.web_url &&
this.author.username;
},
/**
* If information about the author is provided will return a string
* to be rendered as the alt attribute of the img tag.
*
* @returns {String}
*/
userImageAltDescription() {
return this.author &&
this.author.username ? `${this.author.username}'s avatar` : null;
},
},
/**
* In order to reuse the svg instead of copy and paste in this template
* we need to render it outside this component using =custom_icon partial.
* Make sure it has this structure:
* .commit-icon-svg.hidden
* svg
*
* TODO: Find a better way to include SVG
*/
mounted() {
const commitIconContainer = this.$el.querySelector('.commit-icon-container');
const commitIcon = document.querySelector('.commit-icon-svg.hidden svg');
if (commitIconContainer && commitIcon) {
commitIconContainer.appendChild(commitIcon.cloneNode(true));
}
},
template: `
<div class="branch-commit">
<div v-if="hasRef" class="icon-container">
<i v-if="tag" class="fa fa-tag"></i>
<i v-if="!tag" class="fa fa-code-fork"></i>
</div>
<a v-if="hasRef"
class="monospace branch-name"
:href="ref.ref_url"
v-html="ref.name">
</a>
<div class="icon-container commit-icon commit-icon-container">
</div>
<a class="commit-id monospace"
:href="commit_url"
v-html="short_sha">
</a>
<p class="commit-title">
<span v-if="title">
<a v-if="hasAuthor"
class="avatar-image-container"
:href="author.web_url">
<img
class="avatar has-tooltip s20"
:src="author.avatar_url"
:alt="userImageAltDescription"
:title="author.username" />
</a>
<a class="commit-row-message"
:href="commit_url" v-html="title">
</a>
</span>
<span v-else>
Cant find HEAD commit for this branch
</span>
</p>
</div>
`,
});
})();
.environments-container,
.deployments-container { .deployments-container {
width: 100%; width: 100%;
overflow: auto; overflow: auto;
} }
.environments-list-loading {
width: 100%;
font-size: 34px;
}
@media (max-width: $screen-sm-min) {
.environments-container {
width: 100%;
overflow: auto;
}
}
.environments { .environments {
table-layout: fixed;
.deployment-column { .deployment-column {
.avatar { .avatar {
float: none; float: none;
...@@ -15,6 +28,10 @@ ...@@ -15,6 +28,10 @@
margin: 0; margin: 0;
} }
.avatar-image-container {
text-decoration: none;
}
.icon-play { .icon-play {
height: 13px; height: 13px;
width: 12px; width: 12px;
...@@ -38,7 +55,8 @@ ...@@ -38,7 +55,8 @@
color: $gl-dark-link-color; color: $gl-dark-link-color;
} }
.stop-env-link { .stop-env-link,
.external-url {
color: $table-text-gray; color: $table-text-gray;
.stop-env-icon { .stop-env-icon {
...@@ -58,10 +76,29 @@ ...@@ -58,10 +76,29 @@
} }
} }
} }
.children-row .environment-name {
margin-left: 17px;
margin-right: -17px;
}
.folder-icon {
padding: 0 5px 0 0;
}
.folder-name {
cursor: pointer;
.badge {
font-weight: normal;
background-color: $gray-darker;
color: $gl-placeholder-color;
vertical-align: baseline;
}
}
} }
.table.ci-table.environments { .table.ci-table.environments {
.icon-container { .icon-container {
width: 20px; width: 20px;
text-align: center; text-align: center;
......
...@@ -8,13 +8,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController ...@@ -8,13 +8,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def index def index
@scope = params[:scope] @scope = params[:scope]
@all_environments = project.environments @environments = project.environments
@environments =
if @scope == 'stopped' respond_to do |format|
@all_environments.stopped format.html
else format.json do
@all_environments.available render json: EnvironmentSerializer
.new(project: @project)
.represent(@environments)
end end
end
end end
def show def show
......
module EnvironmentsHelper
def environments_list_data
{
endpoint: namespace_project_environments_path(@project.namespace, @project, format: :json)
}
end
end
...@@ -4,21 +4,21 @@ class BuildEntity < Grape::Entity ...@@ -4,21 +4,21 @@ class BuildEntity < Grape::Entity
expose :id expose :id
expose :name expose :name
expose :build_url do |build| expose :build_path do |build|
url_to(:namespace_project_build, build) path_to(:namespace_project_build, build)
end end
expose :retry_url do |build| expose :retry_path do |build|
url_to(:retry_namespace_project_build, build) path_to(:retry_namespace_project_build, build)
end end
expose :play_url, if: ->(build, _) { build.manual? } do |build| expose :play_path, if: ->(build, _) { build.manual? } do |build|
url_to(:play_namespace_project_build, build) path_to(:play_namespace_project_build, build)
end end
private private
def url_to(route, build) def path_to(route, build)
send("#{route}_url", build.project.namespace, build.project, build) send("#{route}_path", build.project.namespace, build.project, build)
end end
end end
...@@ -9,4 +9,11 @@ class CommitEntity < API::Entities::RepoCommit ...@@ -9,4 +9,11 @@ class CommitEntity < API::Entities::RepoCommit
request.project, request.project,
id: commit.id) id: commit.id)
end end
expose :commit_path do |commit|
namespace_project_tree_path(
request.project.namespace,
request.project,
id: commit.id)
end
end end
...@@ -10,8 +10,8 @@ class DeploymentEntity < Grape::Entity ...@@ -10,8 +10,8 @@ class DeploymentEntity < Grape::Entity
deployment.ref deployment.ref
end end
expose :ref_url do |deployment| expose :ref_path do |deployment|
namespace_project_tree_url( namespace_project_tree_path(
deployment.project.namespace, deployment.project.namespace,
deployment.project, deployment.project,
id: deployment.ref) id: deployment.ref)
......
...@@ -9,8 +9,15 @@ class EnvironmentEntity < Grape::Entity ...@@ -9,8 +9,15 @@ class EnvironmentEntity < Grape::Entity
expose :last_deployment, using: DeploymentEntity expose :last_deployment, using: DeploymentEntity
expose :stoppable? expose :stoppable?
expose :environment_url do |environment| expose :environment_path do |environment|
namespace_project_environment_url( namespace_project_environment_path(
environment.project.namespace,
environment.project,
environment)
end
expose :stop_path do |environment|
stop_namespace_project_environment_path(
environment.project.namespace, environment.project.namespace,
environment.project, environment.project,
environment) environment)
......
- last_deployment = environment.last_deployment
%tr.environment
%td
= link_to environment.name, namespace_project_environment_path(@project.namespace, @project, environment)
%td.deployment-column
- if last_deployment
%span ##{last_deployment.iid}
- if last_deployment.user
by
= user_avatar(user: last_deployment.user, size: 20)
%td
- if last_deployment && last_deployment.deployable
= link_to [@project.namespace.becomes(Namespace), @project, last_deployment.deployable], class: 'build-link' do
= "#{last_deployment.deployable.name} (##{last_deployment.deployable.id})"
%td
- if last_deployment
= render 'projects/deployments/commit', deployment: last_deployment
- else
%p.commit-title
No deployments yet
%td
- if last_deployment
#{time_ago_with_tooltip(last_deployment.created_at)}
%td.hidden-xs
.pull-right
= render 'projects/environments/external_url', environment: environment
= render 'projects/deployments/actions', deployment: last_deployment
= render 'projects/environments/stop', environment: environment
= render 'projects/deployments/rollback', deployment: last_deployment
...@@ -2,47 +2,19 @@ ...@@ -2,47 +2,19 @@
- page_title "Environments" - page_title "Environments"
= render "projects/pipelines/head" = render "projects/pipelines/head"
%div{ class: container_class } - content_for :page_specific_javascripts do
.top-area = page_specific_javascript_tag("environments/environments_bundle.js")
%ul.nav-links .commit-icon-svg.hidden
%li{class: ('active' if @scope.nil?)} = custom_icon("icon_commit")
= link_to project_environments_path(@project) do .play-icon-svg.hidden
Available = custom_icon("icon_play")
%span.badge.js-available-environments-count
= number_with_delimiter(@all_environments.available.count)
%li{class: ('active' if @scope == 'stopped')} #environments-list-view{ data: { environments_data: environments_list_data,
= link_to project_environments_path(@project, scope: :stopped) do "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s,
Stopped "can-read-environment" => can?(current_user, :read_environment, @project).to_s,
%span.badge.js-stopped-environments-count "can-create-environment" => can?(current_user, :create_environment, @project).to_s,
= number_with_delimiter(@all_environments.stopped.count) "project-environments-path" => project_environments_path(@project),
"project-stopped-environments-path" => project_environments_path(@project, scope: :stopped),
- if can?(current_user, :create_environment, @project) && !@all_environments.blank? "new-environment-path" => new_namespace_project_environment_path(@project.namespace, @project),
.nav-controls "help-page-path" => help_page_path("ci/environments"),
= link_to new_namespace_project_environment_path(@project.namespace, @project), class: 'btn btn-create' do "css-class" => container_class}}
New environment
.environments-container
- if @all_environments.blank?
.blank-state.blank-state-no-icon
%h2.blank-state-title
You don't have any environments right now.
%p.blank-state-text
Environments are places where code gets deployed, such as staging or production.
%br
= succeed "." do
= link_to "Read more about environments", help_page_path("ci/environments")
- if can?(current_user, :create_environment, @project)
= link_to new_namespace_project_environment_path(@project.namespace, @project), class: 'btn btn-create' do
New environment
- else
.table-holder
%table.table.ci-table.environments
%tbody
%th Environment
%th Last Deployment
%th Build
%th Commit
%th
%th.hidden-xs
= render @environments
---
title: Display "folders" for environments
merge_request: 7015
author:
...@@ -94,6 +94,7 @@ module Gitlab ...@@ -94,6 +94,7 @@ module Gitlab
config.assets.precompile << "cycle_analytics/cycle_analytics_bundle.js" config.assets.precompile << "cycle_analytics/cycle_analytics_bundle.js"
config.assets.precompile << "merge_conflicts/merge_conflicts_bundle.js" config.assets.precompile << "merge_conflicts/merge_conflicts_bundle.js"
config.assets.precompile << "boards/test_utils/simulate_drag.js" config.assets.precompile << "boards/test_utils/simulate_drag.js"
config.assets.precompile << "environments/environments_bundle.js"
config.assets.precompile << "blob_edit/blob_edit_bundle.js" config.assets.precompile << "blob_edit/blob_edit_bundle.js"
config.assets.precompile << "snippet/snippet_bundle.js" config.assets.precompile << "snippet/snippet_bundle.js"
config.assets.precompile << "lib/utils/*.js" config.assets.precompile << "lib/utils/*.js"
......
...@@ -106,6 +106,9 @@ ActiveRecord::Schema.define(version: 20161117114805) do ...@@ -106,6 +106,9 @@ ActiveRecord::Schema.define(version: 20161117114805) do
t.integer "housekeeping_incremental_repack_period", default: 10, null: false t.integer "housekeeping_incremental_repack_period", default: 10, null: false
t.integer "housekeeping_full_repack_period", default: 50, null: false t.integer "housekeeping_full_repack_period", default: 50, null: false
t.integer "housekeeping_gc_period", default: 200, null: false t.integer "housekeeping_gc_period", default: 200, null: false
t.boolean "sidekiq_throttling_enabled", default: false
t.string "sidekiq_throttling_queues"
t.decimal "sidekiq_throttling_factor"
end end
create_table "audit_events", force: :cascade do |t| create_table "audit_events", force: :cascade do |t|
......
require 'spec_helper' require 'spec_helper'
describe Projects::EnvironmentsController do describe Projects::EnvironmentsController do
include ApiHelpers
let(:environment) { create(:environment) } let(:environment) { create(:environment) }
let(:project) { environment.project } let(:project) { environment.project }
let(:user) { create(:user) } let(:user) { create(:user) }
...@@ -11,6 +13,27 @@ describe Projects::EnvironmentsController do ...@@ -11,6 +13,27 @@ describe Projects::EnvironmentsController do
sign_in(user) sign_in(user)
end end
describe 'GET index' do
context 'when standardrequest has been made' do
it 'responds with status code 200' do
get :index, environment_params
expect(response).to be_ok
end
end
context 'when requesting JSON response' do
it 'responds with correct JSON' do
get :index, environment_params(format: :json)
first_environment = json_response.first
expect(first_environment).not_to be_empty
expect(first_environment['name']). to eq environment.name
end
end
end
describe 'GET show' do describe 'GET show' do
context 'with valid id' do context 'with valid id' do
it 'responds with a status code 200' do it 'responds with a status code 200' do
...@@ -48,11 +71,9 @@ describe Projects::EnvironmentsController do ...@@ -48,11 +71,9 @@ describe Projects::EnvironmentsController do
end end
end end
def environment_params def environment_params(opts = {})
{ opts.reverse_merge(namespace_id: project.namespace,
namespace_id: project.namespace, project_id: project,
project_id: project, id: environment.id)
id: environment.id
}
end end
end end
require 'spec_helper'
feature 'Environment', :feature do
given(:project) { create(:empty_project) }
given(:user) { create(:user) }
given(:role) { :developer }
background do
login_as(user)
project.team << [user, role]
end
feature 'environment details page' do
given!(:environment) { create(:environment, project: project) }
given!(:deployment) { }
given!(:manual) { }
before do
visit_environment(environment)
end
context 'without deployments' do
scenario 'does show no deployments' do
expect(page).to have_content('You don\'t have any deployments right now.')
end
end
context 'with deployments' do
context 'when there is no related deployable' do
given(:deployment) do
create(:deployment, environment: environment, deployable: nil)
end
scenario 'does show deployment SHA' do
expect(page).to have_link(deployment.short_sha)
end
scenario 'does not show a re-deploy button for deployment without build' do
expect(page).not_to have_link('Re-deploy')
end
end
context 'with related deployable present' do
given(:pipeline) { create(:ci_pipeline, project: project) }
given(:build) { create(:ci_build, pipeline: pipeline) }
given(:deployment) do
create(:deployment, environment: environment, deployable: build)
end
scenario 'does show build name' do
expect(page).to have_link("#{build.name} (##{build.id})")
end
scenario 'does show re-deploy button' do
expect(page).to have_link('Re-deploy')
end
scenario 'does not show stop button' do
expect(page).not_to have_link('Stop')
end
context 'with manual action' do
given(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'deploy to production') }
scenario 'does show a play button' do
expect(page).to have_link(manual.name.humanize)
end
scenario 'does allow to play manual action' do
expect(manual).to be_skipped
expect{ click_link(manual.name.humanize) }.not_to change { Ci::Pipeline.count }
expect(page).to have_content(manual.name)
expect(manual.reload).to be_pending
end
context 'with external_url' do
given(:environment) { create(:environment, project: project, external_url: 'https://git.gitlab.com') }
given(:build) { create(:ci_build, pipeline: pipeline) }
given(:deployment) { create(:deployment, environment: environment, deployable: build) }
scenario 'does show an external link button' do
expect(page).to have_link(nil, href: environment.external_url)
end
end
context 'with stop action' do
given(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') }
given(:deployment) { create(:deployment, environment: environment, deployable: build, on_stop: 'close_app') }
scenario 'does show stop button' do
expect(page).to have_link('Stop')
end
scenario 'does allow to stop environment' do
click_link('Stop')
expect(page).to have_content('close_app')
end
context 'for reporter' do
let(:role) { :reporter }
scenario 'does not show stop button' do
expect(page).not_to have_link('Stop')
end
end
end
end
end
end
end
feature 'auto-close environment when branch is deleted' do
given(:project) { create(:project) }
given!(:environment) do
create(:environment, :with_review_app, project: project,
ref: 'feature')
end
scenario 'user visits environment page' do
visit_environment(environment)
expect(page).to have_link('Stop')
end
scenario 'user deletes the branch with running environment' do
visit namespace_project_branches_path(project.namespace, project)
remove_branch_with_hooks(project, user, 'feature') do
page.within('.js-branch-feature') { find('a.btn-remove').click }
end
visit_environment(environment)
expect(page).to have_no_link('Stop')
end
##
# This is a workaround for problem described in #24543
#
def remove_branch_with_hooks(project, user, branch)
params = {
oldrev: project.commit(branch).id,
newrev: Gitlab::Git::BLANK_SHA,
ref: "refs/heads/#{branch}"
}
yield
GitPushService.new(project, user, params).execute
end
end
def visit_environment(environment)
visit namespace_project_environment_path(environment.project.namespace,
environment.project,
environment)
end
end
This diff is collapsed.
//= require vue
//= require environments/components/environment_actions
describe('Actions Component', () => {
fixture.preload('environments/element.html');
beforeEach(() => {
fixture.load('environments/element.html');
});
it('Should render a dropdown with the provided actions', () => {
const actionsMock = [
{
name: 'bar',
play_path: 'https://gitlab.com/play',
},
{
name: 'foo',
play_path: '#',
},
];
const component = new window.gl.environmentsList.ActionsComponent({
el: document.querySelector('.test-dom-element'),
propsData: {
actions: actionsMock,
},
});
expect(
component.$el.querySelectorAll('.dropdown-menu li').length
).toEqual(actionsMock.length);
expect(
component.$el.querySelector('.dropdown-menu li a').getAttribute('href')
).toEqual(actionsMock[0].play_path);
});
});
//= require vue
//= require environments/components/environment_external_url
describe('External URL Component', () => {
fixture.preload('environments/element.html');
beforeEach(() => {
fixture.load('environments/element.html');
});
it('should link to the provided external_url', () => {
const externalURL = 'https://gitlab.com';
const component = new window.gl.environmentsList.ExternalUrlComponent({
el: document.querySelector('.test-dom-element'),
propsData: {
external_url: externalURL,
},
});
expect(component.$el.getAttribute('href')).toEqual(externalURL);
expect(component.$el.querySelector('fa-external-link')).toBeDefined();
});
});
//= require vue
//= require environments/components/environment_item
describe('Environment item', () => {
fixture.preload('environments/table.html');
beforeEach(() => {
fixture.load('environments/table.html');
});
describe('When item is folder', () => {
let mockItem;
let component;
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',
},
],
};
component = new window.gl.environmentsList.EnvironmentItem({
el: document.querySelector('tr#environment-row'),
propsData: {
model: mockItem,
toggleRow: () => {},
canCreateDeployment: false,
canReadEnvironment: true,
},
});
});
it('Should render folder icon and name', () => {
expect(component.$el.querySelector('.folder-name').textContent).toContain(mockItem.name);
expect(component.$el.querySelector('.folder-icon')).toBeDefined();
});
it('Should render the number of children in a badge', () => {
expect(component.$el.querySelector('.folder-name .badge').textContent).toContain(mockItem.children.length);
});
});
describe('when item is not folder', () => {
let environment;
let component;
beforeEach(() => {
environment = {
id: 31,
name: 'production',
state: 'stopped',
external_url: 'http://external.com',
environment_type: null,
last_deployment: {
id: 66,
iid: 6,
sha: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
ref: {
name: 'master',
ref_path: 'root/ci-folders/tree/master',
},
tag: true,
'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: 1279,
name: 'deploy',
build_path: '/root/ci-folders/builds/1279',
retry_path: '/root/ci-folders/builds/1279/retry',
},
manual_actions: [
{
name: 'action',
play_path: '/play',
},
],
},
'stoppable?': true,
environment_path: 'root/ci-folders/environments/31',
created_at: '2016-11-07T11:11:16.525Z',
updated_at: '2016-11-10T15:55:58.778Z',
};
component = new window.gl.environmentsList.EnvironmentItem({
el: document.querySelector('tr#environment-row'),
propsData: {
model: environment,
toggleRow: () => {},
canCreateDeployment: true,
canReadEnvironment: true,
},
});
});
it('should render environment name', () => {
expect(component.$el.querySelector('.environment-name').textContent).toEqual(environment.name);
});
describe('With deployment', () => {
it('should render deployment internal id', () => {
expect(
component.$el.querySelector('.deployment-column span').textContent
).toContain(environment.last_deployment.iid);
expect(
component.$el.querySelector('.deployment-column span').textContent
).toContain('#');
});
describe('With user information', () => {
it('should render user avatar with link to profile', () => {
expect(
component.$el.querySelector('.js-deploy-user-container').getAttribute('href')
).toEqual(environment.last_deployment.user.web_url);
});
});
describe('With build url', () => {
it('Should link to build url provided', () => {
expect(
component.$el.querySelector('.build-link').getAttribute('href')
).toEqual(environment.last_deployment.deployable.build_path);
});
it('Should render deployable name and id', () => {
expect(
component.$el.querySelector('.build-link').getAttribute('href')
).toEqual(environment.last_deployment.deployable.build_path);
});
});
describe('With commit information', () => {
it('should render commit component', () => {
expect(
component.$el.querySelector('.js-commit-component')
).toBeDefined();
});
});
});
describe('With manual actions', () => {
it('Should render actions component', () => {
expect(
component.$el.querySelector('.js-manual-actions-container')
).toBeDefined();
});
});
describe('With external URL', () => {
it('should render external url component', () => {
expect(
component.$el.querySelector('.js-external-url-container')
).toBeDefined();
});
});
describe('With stop action', () => {
it('Should render stop action component', () => {
expect(
component.$el.querySelector('.js-stop-component-container')
).toBeDefined();
});
});
describe('With retry action', () => {
it('Should render rollback component', () => {
expect(
component.$el.querySelector('.js-rollback-component-container')
).toBeDefined();
});
});
});
});
//= require vue
//= require environments/components/environment_rollback
describe('Rollback Component', () => {
fixture.preload('environments/element.html');
const retryURL = 'https://gitlab.com/retry';
beforeEach(() => {
fixture.load('environments/element.html');
});
it('Should link to the provided retry_url', () => {
const component = new window.gl.environmentsList.RollbackComponent({
el: document.querySelector('.test-dom-element'),
propsData: {
retry_url: retryURL,
is_last_deployment: true,
},
});
expect(component.$el.getAttribute('href')).toEqual(retryURL);
});
it('Should render Re-deploy label when is_last_deployment is true', () => {
const component = new window.gl.environmentsList.RollbackComponent({
el: document.querySelector('.test-dom-element'),
propsData: {
retry_url: retryURL,
is_last_deployment: true,
},
});
expect(component.$el.querySelector('span').textContent).toContain('Re-deploy');
});
it('Should render Rollback label when is_last_deployment is false', () => {
const component = new window.gl.environmentsList.RollbackComponent({
el: document.querySelector('.test-dom-element'),
propsData: {
retry_url: retryURL,
is_last_deployment: false,
},
});
expect(component.$el.querySelector('span').textContent).toContain('Rollback');
});
});
//= require vue
//= require environments/components/environment_stop
describe('Stop Component', () => {
fixture.preload('environments/element.html');
let stopURL;
let component;
beforeEach(() => {
fixture.load('environments/element.html');
stopURL = '/stop';
component = new window.gl.environmentsList.StopComponent({
el: document.querySelector('.test-dom-element'),
propsData: {
stop_url: stopURL,
},
});
});
it('should link to the provided URL', () => {
expect(component.$el.getAttribute('href')).toEqual(stopURL);
});
it('should have a data-confirm attribute', () => {
expect(component.$el.getAttribute('data-confirm')).toEqual('Are you sure you want to stop this environment?');
});
});
//= require vue
//= require environments/stores/environments_store
//= require ./mock_data
/* globals environmentsList */
(() => {
beforeEach(() => {
gl.environmentsList.EnvironmentsStore.create();
});
describe('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);
});
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');
});
});
describe('toggleFolder', () => {
beforeEach(() => {
gl.environmentsList.EnvironmentsStore.storeEnvironments(environmentsList);
});
it('should toggle the open property for the given environment', () => {
gl.environmentsList.EnvironmentsStore.toggleFolder('review');
const { environments } = gl.environmentsList.EnvironmentsStore.state;
const environment = environments.filter(env => env['vue-isChildren'] === true && env.name === 'review');
expect(environment[0].isOpen).toBe(true);
});
});
});
})();
/* eslint-disable no-unused-vars */
const environmentsList = [
{
id: 31,
name: 'production',
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: [],
},
'stoppable?': 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',
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: [],
},
'stoppable?': false,
environment_path: '/root/ci-folders/environments/31',
created_at: '2016-11-07T11:11:16.525Z',
updated_at: '2016-11-07T11:11:16.525Z',
},
{
id: 33,
name: 'test-environment',
state: 'available',
environment_type: 'review',
last_deployment: null,
'stoppable?': 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: 34,
name: 'test-environment-1',
state: 'available',
environment_type: 'review',
last_deployment: null,
'stoppable?': true,
environment_path: '/root/ci-folders/environments/31',
created_at: '2016-11-07T11:11:16.525Z',
updated_at: '2016-11-07T11:11:16.525Z',
},
];
%div
#environments-list-view{ data: { environments_data: "https://gitlab.com/foo/environments",
"can-create-deployment" => "true",
"can-read-environment" => "true",
"can-create-environment" => "true",
"project-environments-path" => "https://gitlab.com/foo/environments",
"project-stopped-environments-path" => "https://gitlab.com/foo/environments?scope=stopped",
"new-environment-path" => "https://gitlab.com/foo/environments/new",
"help-page-path" => "https://gitlab.com/help_page"}}
%table
%thead
%tr
%th Environment
%th Last deployment
%th Build
%th Commit
%th
%th
%tbody
%tr#environment-row
//= require vue_common_component/commit
describe('Commit component', () => {
let props;
let component;
it('should render a code-fork icon if it does not represent a tag', () => {
fixture.set('<div class="test-commit-container"></div>');
component = new window.gl.CommitComponent({
el: document.querySelector('.test-commit-container'),
propsData: {
tag: false,
ref: {
name: 'master',
ref_url: 'http://localhost/namespace2/gitlabhq/tree/master',
},
commit_url: 'https://gitlab.com/gitlab-org/gitlab-ce/commit/b7836eddf62d663c665769e1b0960197fd215067',
short_sha: 'b7836edd',
title: 'Commit message',
author: {
avatar_url: 'https://gitlab.com/uploads/user/avatar/300478/avatar.png',
web_url: 'https://gitlab.com/jschatz1',
username: 'jschatz1',
},
},
});
expect(component.$el.querySelector('.icon-container i').classList).toContain('fa-code-fork');
});
describe('Given all the props', () => {
beforeEach(() => {
fixture.set('<div class="test-commit-container"></div>');
props = {
tag: true,
ref: {
name: 'master',
ref_url: 'http://localhost/namespace2/gitlabhq/tree/master',
},
commit_url: 'https://gitlab.com/gitlab-org/gitlab-ce/commit/b7836eddf62d663c665769e1b0960197fd215067',
short_sha: 'b7836edd',
title: 'Commit message',
author: {
avatar_url: 'https://gitlab.com/uploads/user/avatar/300478/avatar.png',
web_url: 'https://gitlab.com/jschatz1',
username: 'jschatz1',
},
};
component = new window.gl.CommitComponent({
el: document.querySelector('.test-commit-container'),
propsData: props,
});
});
it('should render a tag icon if it represents a tag', () => {
expect(component.$el.querySelector('.icon-container i').classList).toContain('fa-tag');
});
it('should render a link to the ref url', () => {
expect(component.$el.querySelector('.branch-name').getAttribute('href')).toEqual(props.ref.ref_url);
});
it('should render the ref name', () => {
expect(component.$el.querySelector('.branch-name').textContent).toContain(props.ref.name);
});
it('should render the commit short sha with a link to the commit url', () => {
expect(component.$el.querySelector('.commit-id').getAttribute('href')).toEqual(props.commit_url);
expect(component.$el.querySelector('.commit-id').textContent).toContain(props.short_sha);
});
describe('Given commit title and author props', () => {
it('Should render a link to the author profile', () => {
expect(
component.$el.querySelector('.commit-title .avatar-image-container').getAttribute('href')
).toEqual(props.author.web_url);
});
it('Should render the author avatar with title and alt attributes', () => {
expect(
component.$el.querySelector('.commit-title .avatar-image-container img').getAttribute('title')
).toContain(props.author.username);
expect(
component.$el.querySelector('.commit-title .avatar-image-container img').getAttribute('alt')
).toContain(`${props.author.username}'s avatar`);
});
});
it('should render the commit title', () => {
expect(
component.$el.querySelector('a.commit-row-message').getAttribute('href')
).toEqual(props.commit_url);
expect(
component.$el.querySelector('a.commit-row-message').textContent
).toContain(props.title);
});
});
describe('When commit title is not provided', () => {
it('Should render default message', () => {
fixture.set('<div class="test-commit-container"></div>');
props = {
tag: false,
ref: {
name: 'master',
ref_url: 'http://localhost/namespace2/gitlabhq/tree/master',
},
commit_url: 'https://gitlab.com/gitlab-org/gitlab-ce/commit/b7836eddf62d663c665769e1b0960197fd215067',
short_sha: 'b7836edd',
title: null,
author: {},
};
component = new window.gl.CommitComponent({
el: document.querySelector('.test-commit-container'),
propsData: props,
});
expect(
component.$el.querySelector('.commit-title span').textContent
).toContain('Cant find HEAD commit for this branch');
});
});
});
...@@ -10,9 +10,9 @@ describe BuildEntity do ...@@ -10,9 +10,9 @@ describe BuildEntity do
context 'when build is a regular job' do context 'when build is a regular job' do
let(:build) { create(:ci_build) } let(:build) { create(:ci_build) }
it 'contains url to build page and retry action' do it 'contains paths to build page and retry action' do
expect(subject).to include(:build_url, :retry_url) expect(subject).to include(:build_path, :retry_path)
expect(subject).not_to include(:play_url) expect(subject).not_to include(:play_path)
end end
it 'does not contain sensitive information' do it 'does not contain sensitive information' do
...@@ -24,8 +24,8 @@ describe BuildEntity do ...@@ -24,8 +24,8 @@ describe BuildEntity do
context 'when build is a manual action' do context 'when build is a manual action' do
let(:build) { create(:ci_build, :manual) } let(:build) { create(:ci_build, :manual) }
it 'contains url to play action' do it 'contains path to play action' do
expect(subject).to include(:play_url) expect(subject).to include(:play_path)
end end
end end
end end
...@@ -31,7 +31,11 @@ describe CommitEntity do ...@@ -31,7 +31,11 @@ describe CommitEntity do
end end
end end
it 'contains commit URL' do it 'contains path to commit' do
expect(subject).to include(:commit_path)
end
it 'contains URL to commit' do
expect(subject).to include(:commit_url) expect(subject).to include(:commit_url)
end end
......
...@@ -15,6 +15,6 @@ describe DeploymentEntity do ...@@ -15,6 +15,6 @@ describe DeploymentEntity do
it 'exposes nested information about branch' do it 'exposes nested information about branch' do
expect(subject[:ref][:name]).to eq 'master' expect(subject[:ref][:name]).to eq 'master'
expect(subject[:ref][:ref_url]).not_to be_empty expect(subject[:ref][:ref_path]).not_to be_empty
end end
end end
...@@ -13,6 +13,6 @@ describe EnvironmentEntity do ...@@ -13,6 +13,6 @@ describe EnvironmentEntity do
end end
it 'exposes core elements of environment' do it 'exposes core elements of environment' do
expect(subject).to include(:id, :name, :state, :environment_url) expect(subject).to include(:id, :name, :state, :environment_path)
end end
end end
...@@ -33,7 +33,7 @@ describe EnvironmentSerializer do ...@@ -33,7 +33,7 @@ describe EnvironmentSerializer do
it 'contains important elements of environment' do it 'contains important elements of environment' do
expect(json) expect(json)
.to include(:name, :external_url, :environment_url, :last_deployment) .to include(:name, :external_url, :environment_path, :last_deployment)
end end
it 'contains relevant information about last deployment' do it 'contains relevant information about last deployment' do
......
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