diff options
Diffstat (limited to 'app')
21 files changed, 1317 insertions, 101 deletions
diff --git a/app/assets/javascripts/environments/components/environment.js.es6 b/app/assets/javascripts/environments/components/environment.js.es6 new file mode 100644 index 00000000000..a94fe41dede --- /dev/null +++ b/app/assets/javascripts/environments/components/environment.js.es6 @@ -0,0 +1,250 @@ +//= 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, + 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 window.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) { + if (string === 'true') { + return true; + } + return false; + }, + + methods: { + toggleRow(model) { + return this.store.toggleFolder(model.name); + }, + }, + + template: ` + <div class="container-fluid container-limited"> + <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> + `, + }); +})(); diff --git a/app/assets/javascripts/environments/components/environment_actions.js.es6 b/app/assets/javascripts/environments/components/environment_actions.js.es6 new file mode 100644 index 00000000000..ca1a5861f30 --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_actions.js.es6 @@ -0,0 +1,66 @@ +/*= 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"> + <!-- svg goes here --> + </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"> + <!-- svg goes here --> + </span> + <span v-html="action.name"></span> + </a> + </li> + </ul> + </div> + </div> + `, + }); +})(); diff --git a/app/assets/javascripts/environments/components/environment_external_url.js.es6 b/app/assets/javascripts/environments/components/environment_external_url.js.es6 new file mode 100644 index 00000000000..79cd5ded5bd --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_external_url.js.es6 @@ -0,0 +1,22 @@ +/*= 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> + `, + }); +})(); diff --git a/app/assets/javascripts/environments/components/environment_item.js.es6 b/app/assets/javascripts/environments/components/environment_item.js.es6 new file mode 100644 index 00000000000..871da5790ec --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_item.js.es6 @@ -0,0 +1,451 @@ +/*= require lib/utils/timeago */ +/*= require lib/utils/text_utility */ +/*= require vue_common_component/commit */ +/*= require ./environment_actions */ +/*= require ./environment_external_url */ +/*= require ./environment_stop */ +/*= require ./environment_rollback */ + +/* globals Vue, timeago */ + +(() => { + /** + * Envrionment Item Component + * + * Used in a hierarchical structure to show folders with children + * in a table. + * Recursive component based on [Tree View](https://vuejs.org/examples/tree-view.html) + * + * See this [issue](https://gitlab.com/gitlab-org/gitlab-ce/issues/22539) + * for more information.15 + */ + + window.gl = window.gl || {}; + window.gl.environmentsList = window.gl.environmentsList || {}; + + gl.environmentsList.EnvironmentItem = Vue.component('environment-item', { + + components: { + 'commit-component': window.gl.CommitComponent, + 'actions-component': window.gl.environmentsList.ActionsComponent, + 'external-url-component': window.gl.environmentsList.ExternalUrlComponent, + 'stop-component': window.gl.environmentsList.StopComponent, + 'rollback-component': window.gl.environmentsList.RollbackComponent, + }, + + props: { + model: { + type: Object, + required: true, + default: () => ({}), + }, + + toggleRow: { + type: Function, + required: false, + }, + + canCreateDeployment: { + type: Boolean, + required: false, + default: false, + }, + + canReadEnvironment: { + type: Boolean, + required: false, + default: false, + }, + }, + + data() { + return { + rowClass: { + 'children-row': this.model['vue-isChildren'], + }, + }; + }, + + computed: { + + /** + * If an item has a `children` entry it means it is a folder. + * Folder items have different behaviours - it is possible to toggle + * them and show their children. + * + * @returns {Boolean|Undefined} + */ + isFolder() { + return this.model.children && this.model.children.length > 0; + }, + + /** + * If an item is inside a folder structure will return true. + * Used for css purposes. + * + * @returns {Boolean|undefined} + */ + isChildren() { + return this.model['vue-isChildren']; + }, + + /** + * Counts the number of environments in each folder. + * Used to show a badge with the counter. + * + * @returns {Number|Undefined} The number of environments for the current folder. + */ + childrenCounter() { + return this.model.children && this.model.children.length; + }, + + /** + * Verifies if `last_deployment` key exists in the current Envrionment. + * This key is required to render most of the html - this method works has + * an helper. + * + * @returns {Boolean} + */ + hasLastDeploymentKey() { + if (this.model.last_deployment && this.model.last_deployment !== {}) { + return true; + } + return false; + }, + + /** + * Verifies is the given environment has manual actions. + * Used to verify if we should render them or nor. + * + * @returns {Boolean|Undefined} + */ + hasManualActions() { + return this.model.last_deployment && this.model.last_deployment.manual_actions && + this.model.last_deployment.manual_actions.length > 0; + }, + + /** + * Returns the value of the `stoppable?` key provided in the response. + * + * @returns {Boolean} + */ + isStoppable() { + return this.model['stoppable?']; + }, + + /** + * Verifies if the `deployable` key is present in `last_deployment` key. + * Used to verify whether we should or not render the rollback partial. + * + * @returns {Boolean|Undefined} + */ + canRetry() { + return this.hasLastDeploymentKey && + this.model.last_deployment && + this.model.last_deployment.deployable; + }, + + /** + * Human readable date. + * + * @returns {String} + */ + createdDate() { + const timeagoInstance = new timeago(); // eslint-disable-line + + return timeagoInstance.format(this.model.created_at); + }, + + /** + * Returns the manual actions with the name parsed. + * + * @returns {Array.<Object>|Undefined} + */ + manualActions() { + if (this.hasManualActions) { + return this.model.last_deployment.manual_actions.map((action) => { + const parsedAction = { + name: gl.text.humanize(action.name), + play_path: action.play_path, + }; + return parsedAction; + }); + } + return []; + }, + + /** + * Builds the string used in the user image alt attribute. + * + * @returns {String} + */ + userImageAltDescription() { + if (this.model.last_deployment && + this.model.last_deployment.user && + this.model.last_deployment.user.username) { + return `${this.model.last_deployment.user.username}'s avatar'`; + } + return ''; + }, + + /** + * If provided, returns the commit tag. + * + * @returns {String|Undefined} + */ + commitTag() { + if (this.model.last_deployment && + this.model.last_deployment.tag) { + return this.model.last_deployment.tag; + } + return undefined; + }, + + /** + * If provided, returns the commit ref. + * + * @returns {Object|Undefined} + */ + commitRef() { + if (this.model.last_deployment && + this.model.last_deployment.ref) { + return this.model.last_deployment.ref; + } + return undefined; + }, + + /** + * If provided, returns the commit url. + * + * @returns {String|Undefined} + */ + commitUrl() { + if (this.model.last_deployment && + this.model.last_deployment.commit && + this.model.last_deployment.commit.commit_path) { + return this.model.last_deployment.commit.commit_path; + } + return undefined; + }, + + /** + * If provided, returns the commit short sha. + * + * @returns {String|Undefined} + */ + commitShortSha() { + if (this.model.last_deployment && + this.model.last_deployment.commit && + this.model.last_deployment.commit.short_id) { + return this.model.last_deployment.commit.short_id; + } + return undefined; + }, + + /** + * If provided, returns the commit title. + * + * @returns {String|Undefined} + */ + commitTitle() { + if (this.model.last_deployment && + this.model.last_deployment.commit && + this.model.last_deployment.commit.title) { + return this.model.last_deployment.commit.title; + } + return undefined; + }, + + /** + * If provided, returns the commit tag. + * + * @returns {Object|Undefined} + */ + commitAuthor() { + if (this.model.last_deployment && + this.model.last_deployment.commit && + this.model.last_deployment.commit.author) { + return this.model.last_deployment.commit.author; + } + + return undefined; + }, + + /** + * Verifies if the `retry_path` key is present and returns its value. + * + * @returns {String|Undefined} + */ + retryUrl() { + if (this.model.last_deployment && + this.model.last_deployment.deployable && + this.model.last_deployment.deployable.retry_path) { + return this.model.last_deployment.deployable.retry_path; + } + return undefined; + }, + + /** + * Verifies if the `last?` key is present and returns its value. + * + * @returns {Boolean|Undefined} + */ + isLastDeployment() { + return this.model.last_deployment && this.model.last_deployment['last?']; + }, + + /** + * Builds the name of the builds needed to display both the name and the id. + * + * @returns {String} + */ + buildName() { + if (this.model.last_deployment && + this.model.last_deployment.deployable) { + return `${this.model.last_deployment.deployable.name} #${this.model.last_deployment.deployable.id}`; + } + return ''; + }, + + /** + * Builds the needed string to show the internal id. + * + * @returns {String} + */ + deploymentInternalId() { + if (this.model.last_deployment && + this.model.last_deployment.iid) { + return `#${this.model.last_deployment.iid}`; + } + return ''; + }, + + /** + * Verifies if the user object is present under last_deployment object. + * + * @returns {Boolean} + */ + deploymentHasUser() { + if (this.model.last_deployment && + this.model.last_deployment.user) { + return true; + } + return false; + }, + + /** + * Returns the user object nested with the last_deployment object. + * Used to render the template. + * + * @returns {Object} + */ + deploymentUser() { + if (this.model.last_deployment && this.model.last_deployment.user) { + return this.model.last_deployment.user; + } + return {}; + }, + }, + + template: ` + <tr> + <td v-bind:class="{ 'children-row': isChildren}"> + <a + v-if="!isFolder" + class="environment-name" + :href="model.environment_path" + v-html="model.name"> + </a> + <span v-else v-on:click="toggleRow(model)" class="folder-name"> + <span class="folder-icon"> + <i v-show="model.isOpen" class="fa fa-caret-down"></i> + <i v-show="!model.isOpen" class="fa fa-caret-right"></i> + </span> + + <span v-html="model.name"></span> + + <span class="badge" v-html="childrenCounter"></span> + </span> + </td> + + <td class="deployment-column"> + <span + v-if="!isFolder && model.last_deployment && model.last_deployment.iid" + v-html="deploymentInternalId"> + </span> + + <span v-if="!isFolder && deploymentHasUser"> + by + <a :href="deploymentUser.web_url" class="js-deploy-user-container"> + <img class="avatar has-tooltip s20" + :src="deploymentUser.avatar_url" + :alt="userImageAltDescription" + :title="deploymentUser.username" /> + </a> + </span> + </td> + + <td> + <a v-if="!isFolder && model.last_deployment && model.last_deployment.deployable" + class="build-link" + :href="model.last_deployment.deployable.build_path" + v-html="buildName"> + </a> + </td> + + <td> + <div v-if="!isFolder && hasLastDeploymentKey" class="js-commit-component"> + <commit-component + :tag="commitTag" + :ref="commitRef" + :commit_url="commitUrl" + :short_sha="commitShortSha" + :title="commitTitle" + :author="commitAuthor"> + </commit-component> + </div> + <p v-if="!isFolder && !hasLastDeploymentKey" class="commit-title"> + No deployments yet + </p> + </td> + + <td> + <span + v-if="!isFolder && model.last_deployment" + class="environment-created-date-timeago" + v-html="createdDate"> + </span> + </td> + + <td class="hidden-xs"> + <div v-if="!isFolder"> + <div v-if="hasManualActions && canCreateDeployment" class="inline js-manual-actions-container"> + <actions-component + :actions="manualActions"> + </actions-component> + </div> + + <div v-if="model.external_url && canReadEnvironment" class="inline js-external-url-container"> + <external-url-component + :external_url="model.external_url"> + </external_url-component> + </div> + + <div v-if="isStoppable && canCreateDeployment" class="inline js-stop-component-container"> + <stop-component + :stop_url="model.environment_path"> + </stop-component> + </div> + + <div v-if="canRetry && canCreateDeployment" class="inline js-rollback-component-container"> + <rollback-component + :is_last_deployment="isLastDeployment" + :retry_url="retryUrl"> + </rollback-component> + </div> + </div> + </td> + </tr> + `, + }); +})(); diff --git a/app/assets/javascripts/environments/components/environment_rollback.js.es6 b/app/assets/javascripts/environments/components/environment_rollback.js.es6 new file mode 100644 index 00000000000..55e5c826e07 --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_rollback.js.es6 @@ -0,0 +1,31 @@ +/*= 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> + `, + }); +})(); diff --git a/app/assets/javascripts/environments/components/environment_stop.js.es6 b/app/assets/javascripts/environments/components/environment_stop.js.es6 new file mode 100644 index 00000000000..2fc56b89429 --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_stop.js.es6 @@ -0,0 +1,38 @@ +/*= 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: '', + }, + }, + + computed: { + stopUrl() { + return `${this.stop_url}/stop`; + }, + }, + + methods: { + openConfirmDialog() { + return window.confirm('Are you sure you want to stop this environment?'); // eslint-disable-line + }, + }, + + template: ` + <a v-on:click="openConfirmDialog" + class="btn stop-env-link" + :href="stopUrl" + data-method="post" + rel="nofollow"> + <i class="fa fa-stop stop-env-icon"></i> + </a> + `, + }); +})(); diff --git a/app/assets/javascripts/environments/environments_bundle.js.es6 b/app/assets/javascripts/environments/environments_bundle.js.es6 new file mode 100644 index 00000000000..20eee7976ec --- /dev/null +++ b/app/assets/javascripts/environments/environments_bundle.js.es6 @@ -0,0 +1,21 @@ +//= 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(), + }, + }); +}); diff --git a/app/assets/javascripts/environments/services/environments_service.js.es6 b/app/assets/javascripts/environments/services/environments_service.js.es6 new file mode 100644 index 00000000000..15ec7b76c3d --- /dev/null +++ b/app/assets/javascripts/environments/services/environments_service.js.es6 @@ -0,0 +1,22 @@ +/* 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(); + } +} diff --git a/app/assets/javascripts/environments/stores/environments_store.js.es6 b/app/assets/javascripts/environments/stores/environments_store.js.es6 new file mode 100644 index 00000000000..928786f0741 --- /dev/null +++ b/app/assets/javascripts/environments/stores/environments_store.js.es6 @@ -0,0 +1,139 @@ +/* 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 environmnetsCopy = environments.map((env) => { + if (env['vue-isChildren'] === true && env.name === envType) { + env.isOpen = !env.isOpen; + } + + return env; + }); + + this.state.environments = environmnetsCopy; + + return environmnetsCopy; + }, + + /** + * 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(); + + if (nameA < nameB) { + return -1; + } + + if (nameA > nameB) { + return 1; + } + + return 0; + }, + }; +})(); diff --git a/app/assets/javascripts/environments/vue_resource_interceptor.js.es6 b/app/assets/javascripts/environments/vue_resource_interceptor.js.es6 new file mode 100644 index 00000000000..406bdbc1c7d --- /dev/null +++ b/app/assets/javascripts/environments/vue_resource_interceptor.js.es6 @@ -0,0 +1,12 @@ +/* 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 + }); +}); diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 5b4123a483b..ac44b81ee22 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -112,6 +112,9 @@ gl.text.removeListeners = function(form) { 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 string.substr(0, (maxLength - 3)) + '...'; }; diff --git a/app/assets/javascripts/vue_common_component/commit.js.es6 b/app/assets/javascripts/vue_common_component/commit.js.es6 new file mode 100644 index 00000000000..d449c550450 --- /dev/null +++ b/app/assets/javascripts/vue_common_component/commit.js.es6 @@ -0,0 +1,178 @@ +/*= 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"> + <!-- svg goes here --> + </div> + + <a class="commit-id monospace" + :href="commit_url" + v-html="short_sha"> + </a> + + <p class="commit-title"> + <span v-if="title"> + <!-- commit author info--> + <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> + `, + }); +})(); diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index fc49ff780fc..e9ff43a8adb 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -1,10 +1,23 @@ -.environments-container, .deployments-container { width: 100%; overflow: auto; } +.environments-list-loading { + width: 100%; + font-size: 34px; +} + +@media (max-width: $screen-sm-min) { + .environments-container { + width: 100%; + overflow: auto; + } +} + .environments { + table-layout: fixed; + .deployment-column { .avatar { float: none; @@ -15,6 +28,10 @@ margin: 0; } + .avatar-image-container { + text-decoration: none; + } + .icon-play { height: 13px; width: 12px; @@ -38,7 +55,8 @@ color: $gl-dark-link-color; } - .stop-env-link { + .stop-env-link, + .external-url { color: $table-text-gray; .stop-env-icon { @@ -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 { - .icon-container { width: 20px; text-align: center; diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index ea22b2dcc15..6bd4cb3f2f5 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -8,13 +8,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController def index @scope = params[:scope] - @all_environments = project.environments - @environments = - if @scope == 'stopped' - @all_environments.stopped - else - @all_environments.available + @environments = project.environments + + respond_to do |format| + format.html + format.json do + render json: EnvironmentSerializer + .new(project: @project) + .represent(@environments) end + end end def show diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb new file mode 100644 index 00000000000..515e802e01e --- /dev/null +++ b/app/helpers/environments_helper.rb @@ -0,0 +1,7 @@ +module EnvironmentsHelper + def environments_list_data + { + endpoint: namespace_project_environments_path(@project.namespace, @project, format: :json) + } + end +end diff --git a/app/serializers/build_entity.rb b/app/serializers/build_entity.rb index 3d9ac66de0e..cf1c418a88e 100644 --- a/app/serializers/build_entity.rb +++ b/app/serializers/build_entity.rb @@ -4,21 +4,21 @@ class BuildEntity < Grape::Entity expose :id expose :name - expose :build_url do |build| - url_to(:namespace_project_build, build) + expose :build_path do |build| + path_to(:namespace_project_build, build) end - expose :retry_url do |build| - url_to(:retry_namespace_project_build, build) + expose :retry_path do |build| + path_to(:retry_namespace_project_build, build) end - expose :play_url, if: ->(build, _) { build.manual? } do |build| - url_to(:play_namespace_project_build, build) + expose :play_path, if: ->(build, _) { build.manual? } do |build| + path_to(:play_namespace_project_build, build) end private - def url_to(route, build) - send("#{route}_url", build.project.namespace, build.project, build) + def path_to(route, build) + send("#{route}_path", build.project.namespace, build.project, build) end end diff --git a/app/serializers/commit_entity.rb b/app/serializers/commit_entity.rb index f7eba6fc1e3..bc92a1c8545 100644 --- a/app/serializers/commit_entity.rb +++ b/app/serializers/commit_entity.rb @@ -3,8 +3,8 @@ class CommitEntity < API::Entities::RepoCommit expose :author, using: UserEntity - expose :commit_url do |commit| - namespace_project_tree_url( + expose :commit_path do |commit| + namespace_project_tree_path( request.project.namespace, request.project, id: commit.id) diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb index ad6fc8d665b..d610fbe0c8a 100644 --- a/app/serializers/deployment_entity.rb +++ b/app/serializers/deployment_entity.rb @@ -10,8 +10,8 @@ class DeploymentEntity < Grape::Entity deployment.ref end - expose :ref_url do |deployment| - namespace_project_tree_url( + expose :ref_path do |deployment| + namespace_project_tree_path( deployment.project.namespace, deployment.project, id: deployment.ref) diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb index ee4392cc46d..93534ef1b15 100644 --- a/app/serializers/environment_entity.rb +++ b/app/serializers/environment_entity.rb @@ -9,8 +9,8 @@ class EnvironmentEntity < Grape::Entity expose :last_deployment, using: DeploymentEntity expose :stoppable? - expose :environment_url do |environment| - namespace_project_environment_url( + expose :environment_path do |environment| + namespace_project_environment_path( environment.project.namespace, environment.project, environment) diff --git a/app/views/projects/environments/_environment.html.haml b/app/views/projects/environments/_environment.html.haml deleted file mode 100644 index b75d5df4150..00000000000 --- a/app/views/projects/environments/_environment.html.haml +++ /dev/null @@ -1,35 +0,0 @@ -- 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 diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index 8f555afcf11..b641d2cec34 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -2,47 +2,18 @@ - page_title "Environments" = render "projects/pipelines/head" -%div{ class: container_class } - .top-area - %ul.nav-links - %li{class: ('active' if @scope.nil?)} - = link_to project_environments_path(@project) do - Available - %span.badge.js-available-environments-count - = number_with_delimiter(@all_environments.available.count) +- content_for :page_specific_javascripts do + = page_specific_javascript_tag("environments/environments_bundle.js") +.commit-icon-svg.hidden + = custom_icon("icon_commit") +.play-icon-svg.hidden + = custom_icon("icon_play") - %li{class: ('active' if @scope == 'stopped')} - = link_to project_environments_path(@project, scope: :stopped) do - Stopped - %span.badge.js-stopped-environments-count - = number_with_delimiter(@all_environments.stopped.count) - - - if can?(current_user, :create_environment, @project) && !@all_environments.blank? - .nav-controls - = link_to new_namespace_project_environment_path(@project.namespace, @project), class: 'btn btn-create' do - 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 +#environments-list-view{ data: { environments_data: environments_list_data, + "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s, + "can-read-environment" => can?(current_user, :read_environment, @project).to_s, + "can-create-environment" => can?(current_user, :create_environment, @project).to_s, + "project-environments-path" => project_environments_path(@project), + "project-stopped-environments-path" => project_environments_path(@project, scope: :stopped), + "new-environment-path" => new_namespace_project_environment_path(@project.namespace, @project), + "help-page-path" => help_page_path("ci/environments")}, class: container_class } |