Commit aa2d40a2 authored by Filipa Lacerda's avatar Filipa Lacerda
Browse files

Merge branch 'mr-widget-deployment-styling' into 'master'

Fix styling of multiple environments in merge request widget

Closes #40610

See merge request gitlab-org/gitlab-ce!17663
<script>
import timeagoMixin from '../../vue_shared/mixins/timeago';
import tooltip from '../../vue_shared/directives/tooltip';
import LoadingButton from '../../vue_shared/components/loading_button.vue';
import { visitUrl } from '../../lib/utils/url_utility';
import createFlash from '../../flash';
import MemoryUsage from './memory_usage.vue';
import StatusIcon from './mr_widget_status_icon.vue';
import MRWidgetService from '../services/mr_widget_service';
export default {
name: 'Deployment',
components: {
LoadingButton,
MemoryUsage,
StatusIcon,
},
directives: {
tooltip,
},
mixins: [
timeagoMixin,
],
props: {
deployment: {
type: Object,
required: true,
},
},
data() {
return {
isStopping: false,
};
},
computed: {
deployTimeago() {
return this.timeFormated(this.deployment.deployed_at);
},
hasExternalUrls() {
return !!(this.deployment.external_url && this.deployment.external_url_formatted);
},
hasDeploymentTime() {
return !!(this.deployment.deployed_at && this.deployment.deployed_at_formatted);
},
hasDeploymentMeta() {
return !!(this.deployment.url && this.deployment.name);
},
hasMetrics() {
return !!(this.deployment.metrics_url);
},
},
methods: {
stopEnvironment() {
const msg = 'Are you sure you want to stop this environment?';
const isConfirmed = confirm(msg); // eslint-disable-line
if (isConfirmed) {
this.isStopping = true;
MRWidgetService.stopEnvironment(this.deployment.stop_url)
.then(res => res.data)
.then((data) => {
if (data.redirect_url) {
visitUrl(data.redirect_url);
}
this.isStopping = false;
})
.catch(() => {
createFlash('Something went wrong while stopping this environment. Please try again.');
this.isStopping = false;
});
}
},
},
};
</script>
<template>
<div class="mr-widget-heading deploy-heading">
<div class="ci-widget media">
<div class="ci-status-icon ci-status-icon-success">
<span class="js-icon-link icon-link">
<status-icon status="success" />
</span>
</div>
<div class="media-body">
<div class="deploy-body">
<template v-if="hasDeploymentMeta">
<span>
Deployed to
</span>
<a
:href="deployment.url"
target="_blank"
rel="noopener noreferrer nofollow"
class="deploy-link js-deploy-meta"
>
{{ deployment.name }}
</a>
</template>
<template v-if="hasExternalUrls">
<span>
on
</span>
<a
:href="deployment.external_url"
target="_blank"
rel="noopener noreferrer nofollow"
class="deploy-link js-deploy-url"
>
<i
class="fa fa-external-link"
aria-hidden="true"
>
</i>
{{ deployment.external_url_formatted }}
</a>
</template>
<span
v-if="hasDeploymentTime"
v-tooltip
:title="deployment.deployed_at_formatted"
class="js-deploy-time"
>
{{ deployTimeago }}
</span>
<loading-button
v-if="deployment.stop_url"
container-class="btn btn-default btn-xs prepend-left-default"
label="Stop environment"
:loading="isStopping"
@click="stopEnvironment"
/>
</div>
<memory-usage
v-if="hasMetrics"
:metrics-url="deployment.metrics_url"
:metrics-monitoring-url="deployment.metrics_monitoring_url"
/>
</div>
</div>
</div>
</template>
import { getTimeago } from '~/lib/utils/datetime_utility';
import { visitUrl } from '../../lib/utils/url_utility';
import Flash from '../../flash';
import MemoryUsage from './memory_usage.vue';
import StatusIcon from './mr_widget_status_icon.vue';
import MRWidgetService from '../services/mr_widget_service';
export default {
name: 'MRWidgetDeployment',
props: {
mr: { type: Object, required: true },
service: { type: Object, required: true },
},
components: {
MemoryUsage,
StatusIcon,
},
methods: {
formatDate(date) {
return getTimeago().format(date);
},
hasExternalUrls(deployment = {}) {
return deployment.external_url && deployment.external_url_formatted;
},
hasDeploymentTime(deployment = {}) {
return deployment.deployed_at && deployment.deployed_at_formatted;
},
hasDeploymentMeta(deployment = {}) {
return deployment.url && deployment.name;
},
stopEnvironment(deployment) {
const msg = 'Are you sure you want to stop this environment?';
const isConfirmed = confirm(msg); // eslint-disable-line
if (isConfirmed) {
MRWidgetService.stopEnvironment(deployment.stop_url)
.then(res => res.data)
.then((data) => {
if (data.redirect_url) {
visitUrl(data.redirect_url);
}
})
.catch(() => {
new Flash('Something went wrong while stopping this environment. Please try again.'); // eslint-disable-line
});
}
},
},
template: `
<div class="mr-widget-heading deploy-heading">
<div v-for="deployment in mr.deployments">
<div class="ci-widget media">
<div class="ci-status-icon ci-status-icon-success">
<span class="js-icon-link icon-link">
<status-icon status="success" />
</span>
</div>
<div class="media-body space-children">
<span>
<span
v-if="hasDeploymentMeta(deployment)">
Deployed to
</span>
<a
v-if="hasDeploymentMeta(deployment)"
:href="deployment.url"
target="_blank"
rel="noopener noreferrer nofollow"
class="js-deploy-meta inline">
{{deployment.name}}
</a>
<span
v-if="hasExternalUrls(deployment)">
on
</span>
<a
v-if="hasExternalUrls(deployment)"
:href="deployment.external_url"
target="_blank"
rel="noopener noreferrer nofollow"
class="js-deploy-url inline">
<i
class="fa fa-external-link"
aria-hidden="true" />
{{deployment.external_url_formatted}}
</a>
<span
v-if="hasDeploymentTime(deployment)"
:data-title="deployment.deployed_at_formatted"
class="js-deploy-time"
data-toggle="tooltip"
data-placement="top">
{{formatDate(deployment.deployed_at)}}
</span>
</span>
<button
type="button"
v-if="deployment.stop_url"
@click="stopEnvironment(deployment)"
class="btn btn-default btn-xs">
Stop environment
</button>
<memory-usage
v-if="deployment.metrics_url"
:metrics-url="deployment.metrics_url"
:metrics-monitoring-url="deployment.metrics_monitoring_url"
/>
</div>
</div>
</div>
</div>
`,
};
......@@ -14,7 +14,7 @@ export { default as SmartInterval } from '~/smart_interval';
export { default as WidgetHeader } from './components/mr_widget_header.vue';
export { default as WidgetMergeHelp } from './components/mr_widget_merge_help.vue';
export { default as WidgetPipeline } from './components/mr_widget_pipeline.vue';
export { default as WidgetDeployment } from './components/mr_widget_deployment';
export { default as Deployment } from './components/deployment.vue';
export { default as WidgetMaintainerEdit } from './components/mr_widget_maintainer_edit.vue';
export { default as WidgetRelatedLinks } from './components/mr_widget_related_links.vue';
export { default as MergedState } from './components/states/mr_widget_merged.vue';
......
......@@ -5,7 +5,7 @@ import {
WidgetHeader,
WidgetMergeHelp,
WidgetPipeline,
WidgetDeployment,
Deployment,
WidgetMaintainerEdit,
WidgetRelatedLinks,
MergedState,
......@@ -67,9 +67,6 @@ export default {
shouldRenderRelatedLinks() {
return !!this.mr.relatedLinks && !this.mr.isNothingToMergeState;
},
shouldRenderDeployments() {
return this.mr.deployments.length;
},
shouldRenderSourceBranchRemovalStatus() {
return !this.mr.canRemoveSourceBranch && this.mr.shouldRemoveSourceBranch &&
(!this.mr.isNothingToMergeState && !this.mr.isMergedState);
......@@ -216,7 +213,7 @@ export default {
'mr-widget-header': WidgetHeader,
'mr-widget-merge-help': WidgetMergeHelp,
'mr-widget-pipeline': WidgetPipeline,
'mr-widget-deployment': WidgetDeployment,
Deployment,
'mr-widget-maintainer-edit': WidgetMaintainerEdit,
'mr-widget-related-links': WidgetRelatedLinks,
'mr-widget-merged': MergedState,
......@@ -250,10 +247,11 @@ export default {
:ci-status="mr.ciStatus"
:has-ci="mr.hasCI"
/>
<mr-widget-deployment
v-if="shouldRenderDeployments"
:mr="mr"
:service="service" />
<deployment
v-for="deployment in mr.deployments"
:key="deployment.id"
:deployment="deployment"
/>
<div class="mr-widget-section">
<component
:is="componentName"
......
......@@ -718,6 +718,8 @@
}
.mr-memory-usage {
width: 100%;
p.usage-info-loading .usage-info-load-spinner {
margin-right: 10px;
font-size: 16px;
......@@ -727,3 +729,36 @@
.fork-sprite {
margin-right: -5px;
}
.deploy-heading {
.media-body {
min-width: 0;
}
}
.deploy-body {
display: flex;
flex-wrap: wrap;
@media (min-width: $screen-xs) {
flex-wrap: nowrap;
white-space: nowrap;
}
> *:not(:last-child) {
margin-right: .3em;
}
}
.deploy-link {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 100px;
max-width: 150px;
@media (min-width: $screen-xs) {
min-width: 0;
max-width: 100%;
}
}
......@@ -22,7 +22,7 @@
wait_for_requests
expect(page).to have_content("Deployed to #{environment.name}")
expect(find('.js-deploy-time')['data-title']).to eq(deployment.created_at.to_time.in_time_zone.to_s(:medium))
expect(find('.js-deploy-time')['data-original-title']).to eq(deployment.created_at.to_time.in_time_zone.to_s(:medium))
end
context 'with stop action' do
......
import Vue from 'vue';
import * as urlUtils from '~/lib/utils/url_utility';
import deploymentComponent from '~/vue_merge_request_widget/components/mr_widget_deployment';
import deploymentComponent from '~/vue_merge_request_widget/components/deployment.vue';
import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service';
import { getTimeago } from '~/lib/utils/datetime_utility';
const deploymentMockData = [
{
id: 15,
name: 'review/diplo',
url: '/root/acets-review-apps/environments/15',
stop_url: '/root/acets-review-apps/environments/15/stop',
metrics_url: '/root/acets-review-apps/environments/15/deployments/1/metrics',
metrics_monitoring_url: '/root/acets-review-apps/environments/15/metrics',
external_url: 'http://diplo.',
external_url_formatted: 'diplo.',
deployed_at: '2017-03-22T22:44:42.258Z',
deployed_at_formatted: 'Mar 22, 2017 10:44pm',
},
];
const deploymentMockData = {
id: 15,
name: 'review/diplo',
url: '/root/acets-review-apps/environments/15',
stop_url: '/root/acets-review-apps/environments/15/stop',
metrics_url: '/root/acets-review-apps/environments/15/deployments/1/metrics',
metrics_monitoring_url: '/root/acets-review-apps/environments/15/metrics',
external_url: 'http://diplo.',
external_url_formatted: 'diplo.',
deployed_at: '2017-03-22T22:44:42.258Z',
deployed_at_formatted: 'Mar 22, 2017 10:44pm',
};
const createComponent = () => {
const Component = Vue.extend(deploymentComponent);
const mr = {
deployments: deploymentMockData,
};
const service = {};
return new Component({
el: document.createElement('div'),
propsData: { mr, service },
propsData: { deployment: { ...deploymentMockData } },
});
};
describe('MRWidgetDeployment', () => {
describe('props', () => {
it('should have props', () => {
const { mr, service } = deploymentComponent.props;
expect(mr.type instanceof Object).toBeTruthy();
expect(mr.required).toBeTruthy();
describe('Deployment component', () => {
let vm;
expect(service.type instanceof Object).toBeTruthy();
expect(service.required).toBeTruthy();
});
beforeEach(() => {
vm = createComponent();
});
describe('methods', () => {
let vm = createComponent();
const deployment = deploymentMockData[0];
afterEach(() => {
vm.$destroy();
});
describe('formatDate', () => {
it('should work', () => {
const readable = getTimeago().format(deployment.deployed_at);
expect(vm.formatDate(deployment.deployed_at)).toEqual(readable);
describe('computed', () => {
describe('deployTimeago', () => {
it('return formatted date', () => {
const readable = getTimeago().format(deploymentMockData.deployed_at);
expect(vm.deployTimeago).toEqual(readable);
});
});
describe('hasExternalUrls', () => {
it('should return true', () => {
expect(vm.hasExternalUrls(deployment)).toBeTruthy();
expect(vm.hasExternalUrls).toEqual(true);
});
it('should return false when deployment has no external_url_formatted', () => {
vm.deployment.external_url_formatted = null;
expect(vm.hasExternalUrls).toEqual(false);
});
it('should return false when there is not enough information', () => {
expect(vm.hasExternalUrls()).toBeFalsy();
expect(vm.hasExternalUrls({ external_url: 'Diplo' })).toBeFalsy();
expect(vm.hasExternalUrls({ external_url_formatted: 'Diplo' })).toBeFalsy();
it('should return false when deployment has no external_url', () => {
vm.deployment.external_url = null;
expect(vm.hasExternalUrls).toEqual(false);
});
});
describe('hasDeploymentTime', () => {
it('should return true', () => {
expect(vm.hasDeploymentTime(deployment)).toBeTruthy();
expect(vm.hasDeploymentTime).toEqual(true);
});
it('should return false when there is not enough information', () => {
expect(vm.hasDeploymentTime()).toBeFalsy();
expect(vm.hasDeploymentTime({ deployed_at: 'Diplo' })).toBeFalsy();
expect(vm.hasDeploymentTime({ deployed_at_formatted: 'Diplo' })).toBeFalsy();
it('should return false when deployment has no deployed_at', () => {
vm.deployment.deployed_at = null;
expect(vm.hasDeploymentTime).toEqual(false);
});
it('should return false when deployment has no deployed_at_formatted', () => {
vm.deployment.deployed_at_formatted = null;
expect(vm.hasDeploymentTime).toEqual(false);
});
});
describe('hasDeploymentMeta', () => {
it('should return true', () => {
expect(vm.hasDeploymentMeta(deployment)).toBeTruthy();
expect(vm.hasDeploymentMeta).toEqual(true);
});
it('should return false when deployment has no url', () => {
vm.deployment.url = null;
expect(vm.hasDeploymentMeta).toEqual(false);
});
it('should return false when there is not enough information', () => {
expect(vm.hasDeploymentMeta()).toBeFalsy();
expect(vm.hasDeploymentMeta({ url: 'Diplo' })).toBeFalsy();
expect(vm.hasDeploymentMeta({ name: 'Diplo' })).toBeFalsy();
it('should return false when deployment has no name', () => {
vm.deployment.name = null;
expect(vm.hasDeploymentMeta).toEqual(false);
});
});
});
describe('methods', () => {
describe('stopEnvironment', () => {
const url = '/foo/bar';
const returnPromise = () => new Promise((resolve) => {
......@@ -131,49 +140,33 @@ describe('MRWidgetDeployment', () => {
});
describe('template', () => {
let vm;
let el;
const [deployment] = deploymentMockData;
beforeEach(() => {
vm = createComponent(deploymentMockData);
el = vm.$el;
});
it('should render template elements correctly', () => {
expect(el.classList.contains('mr-widget-heading')).toBeTruthy();
expect(el.querySelector('.js-icon-link')).toBeDefined();
expect(el.querySelector('.js-deploy-meta').getAttribute('href')).toEqual(deployment.url);
expect(el.querySelector('.js-deploy-meta').innerText).toContain(deployment.name);
expect(el.querySelector('.js-deploy-url').getAttribute('href')).toEqual(deployment.external_url);
expect(el.querySelector('.js-deploy-url').innerText).toContain(deployment.external_url_formatted);
expect(el.querySelector('.js-deploy-time').innerText).toContain(vm.formatDate(deployment.deployed_at));
expect(el.querySelector('.js-mr-memory-usage')).toBeDefined();
expect(el.querySelector('button')).toBeDefined();
it('renders deployment name', () => {
expect(el.querySelector('.js-deploy-meta').getAttribute('href')).toEqual(deploymentMockData.url);
expect(el.querySelector('.js-deploy-meta').innerText).toContain(deploymentMockData.name);
});
it('should list multiple deployments', (done) => {
vm.mr.deployments.push(deployment);
vm.mr.deployments.push(deployment);
it('renders external URL', () => {
expect(el.querySelector('.js-deploy-url').getAttribute('href')).toEqual(deploymentMockData.external_url);
expect(el.querySelector('.js-deploy-url').innerText).toContain(deploymentMockData.external_url_formatted);
});
Vue.nextTick(() => {
expect(el.querySelectorAll('.ci-widget').length).toEqual(3);
expect(el.querySelectorAll('.js-mr-memory-usage').length).toEqual(3);
done();
});
it('renders stop button', () => {
expect(el.querySelector('.btn')).not.toBeNull();
});
it('should not have some elements when there is not enough data', (done) => {
vm.mr.deployments = [{}];
it('renders deployment time', () => {
expect(el.querySelector('.js-deploy-time').innerText).toContain(vm.deployTimeago);