Commit 6e82de21 authored by Phil Hughes's avatar Phil Hughes

Merge branch '31849-pipeline-real-time-header' into 'master'

Pipeline show view real time header section

See merge request !11797
parents 68112433 228b73d5
No related merge requests found
......@@ -118,7 +118,7 @@ export default Vue.component('pipelines-table', {
eventHub.$on('refreshPipelines', this.fetchPipelines);
},
beforeDestroyed() {
beforeDestroy() {
eventHub.$off('refreshPipelines');
},
......
......@@ -109,7 +109,7 @@ export default {
eventHub.$on('postAction', this.postAction);
},
beforeDestroyed() {
beforeDestroy() {
eventHub.$off('toggleFolder');
eventHub.$off('postAction');
},
......
<script>
import ciHeader from '../../vue_shared/components/header_ci_component.vue';
import eventHub from '../event_hub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
name: 'PipelineHeaderSection',
props: {
pipeline: {
type: Object,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
},
components: {
ciHeader,
loadingIcon,
},
data() {
return {
actions: this.getActions(),
};
},
computed: {
status() {
return this.pipeline.details && this.pipeline.details.status;
},
shouldRenderContent() {
return !this.isLoading && Object.keys(this.pipeline).length;
},
},
methods: {
postAction(action) {
const index = this.actions.indexOf(action);
this.$set(this.actions[index], 'isLoading', true);
eventHub.$emit('headerPostAction', action);
},
getActions() {
const actions = [];
if (this.pipeline.retry_path) {
actions.push({
label: 'Retry',
path: this.pipeline.retry_path,
cssClass: 'js-retry-button btn btn-inverted-secondary',
type: 'button',
isLoading: false,
});
}
if (this.pipeline.cancel_path) {
actions.push({
label: 'Cancel running',
path: this.pipeline.cancel_path,
cssClass: 'js-btn-cancel-pipeline btn btn-danger',
type: 'button',
isLoading: false,
});
}
return actions;
},
},
watch: {
pipeline() {
this.actions = this.getActions();
},
},
};
</script>
<template>
<div class="pipeline-header-container">
<ci-header
v-if="shouldRenderContent"
:status="status"
item-name="Pipeline"
:item-id="pipeline.id"
:time="pipeline.created_at"
:user="pipeline.user"
:actions="actions"
@actionClicked="postAction"
/>
<loading-icon
v-else
size="2"/>
</div>
</template>
......@@ -33,7 +33,7 @@ export default {
<user-avatar-link
v-if="user"
class="js-pipeline-url-user"
:link-href="pipeline.user.web_url"
:link-href="pipeline.user.path"
:img-src="pipeline.user.avatar_url"
:tooltip-text="pipeline.user.name"
/>
......
/* global Flash */
import Vue from 'vue';
import PipelinesMediator from './pipeline_details_mediatior';
import pipelineGraph from './components/graph/graph_component.vue';
import pipelineHeader from './components/header_component.vue';
import eventHub from './event_hub';
document.addEventListener('DOMContentLoaded', () => {
const dataset = document.querySelector('.js-pipeline-details-vue').dataset;
......@@ -9,7 +13,8 @@ document.addEventListener('DOMContentLoaded', () => {
mediator.fetchPipeline();
const pipelineGraphApp = new Vue({
// eslint-disable-next-line
new Vue({
el: '#js-pipeline-graph-vue',
data() {
return {
......@@ -29,5 +34,37 @@ document.addEventListener('DOMContentLoaded', () => {
},
});
return pipelineGraphApp;
// eslint-disable-next-line
new Vue({
el: '#js-pipeline-header-vue',
data() {
return {
mediator,
};
},
components: {
pipelineHeader,
},
created() {
eventHub.$on('headerPostAction', this.postAction);
},
beforeDestroy() {
eventHub.$off('headerPostAction', this.postAction);
},
methods: {
postAction(action) {
this.mediator.service.postAction(action.path)
.then(() => this.mediator.refreshPipeline())
.catch(() => new Flash('An error occurred while making the request.'));
},
},
render(createElement) {
return createElement('pipeline-header', {
props: {
isLoading: this.mediator.state.isLoading,
pipeline: this.mediator.store.state.pipeline,
},
});
},
});
});
......@@ -26,6 +26,8 @@ export default class pipelinesMediator {
if (!Visibility.hidden()) {
this.state.isLoading = true;
this.poll.makeRequest();
} else {
this.refreshPipeline();
}
Visibility.change(() => {
......@@ -48,4 +50,10 @@ export default class pipelinesMediator {
this.state.isLoading = false;
return new Flash('An error occurred while fetching the pipeline.');
}
refreshPipeline() {
this.service.getPipeline()
.then(response => this.successCallback(response))
.catch(() => this.errorCallback());
}
}
......@@ -169,7 +169,7 @@ export default {
eventHub.$on('refreshPipelines', this.fetchPipelines);
},
beforeDestroyed() {
beforeDestroy() {
eventHub.$off('refreshPipelines');
},
......
......@@ -11,4 +11,9 @@ export default class PipelineService {
getPipeline() {
return this.pipeline.get();
}
// eslint-disable-next-line
postAction(endpoint) {
return Vue.http.post(`${endpoint}.json`);
}
}
......@@ -33,8 +33,6 @@ export default class PipelinesService {
/**
* Post request for all pipelines actions.
* Endpoint content type needs to be:
* `Content-Type:application/x-www-form-urlencoded`
*
* @param {String} endpoint
* @return {Promise}
......
......@@ -91,7 +91,7 @@ export default {
hasAuthor() {
return this.author &&
this.author.avatar_url &&
this.author.web_url &&
this.author.path &&
this.author.username;
},
......@@ -140,7 +140,7 @@ export default {
<user-avatar-link
v-if="hasAuthor"
class="avatar-image-container"
:link-href="author.web_url"
:link-href="author.path"
:img-src="author.avatar_url"
:img-alt="userImageAltDescription"
:tooltip-text="author.username"
......
<script>
import ciIconBadge from './ci_badge_link.vue';
import loadingIcon from './loading_icon.vue';
import timeagoTooltip from './time_ago_tooltip.vue';
import tooltipMixin from '../mixins/tooltip';
import userAvatarLink from './user_avatar/user_avatar_link.vue';
import userAvatarImage from './user_avatar/user_avatar_image.vue';
/**
* Renders header component for job and pipeline page based on UI mockups
......@@ -31,7 +32,8 @@ export default {
},
user: {
type: Object,
required: true,
required: false,
default: () => ({}),
},
actions: {
type: Array,
......@@ -46,8 +48,9 @@ export default {
components: {
ciIconBadge,
loadingIcon,
timeagoTooltip,
userAvatarLink,
userAvatarImage,
},
computed: {
......@@ -58,13 +61,13 @@ export default {
methods: {
onClickAction(action) {
this.$emit('postAction', action);
this.$emit('actionClicked', action);
},
},
};
</script>
<template>
<header class="page-content-header top-area">
<header class="page-content-header">
<section class="header-main-content">
<ci-icon-badge :status="status" />
......@@ -79,21 +82,23 @@ export default {
by
<user-avatar-link
:link-href="user.web_url"
<template v-if="user">
<a
:href="user.path"
:title="user.email"
class="js-user-link commit-committer-link"
ref="tooltip">
<user-avatar-image
:img-src="user.avatar_url"
:img-alt="userAvatarAltText"
:tooltip-text="user.name"
:img-size="24"
/>
<a
:href="user.web_url"
:title="user.email"
class="js-user-link commit-committer-link"
ref="tooltip">
{{user.name}}
</a>
</template>
</section>
<section
......@@ -111,11 +116,17 @@ export default {
<button
v-else="action.type === 'button'"
@click="onClickAction(action)"
:disabled="action.isLoading"
:class="action.cssClass"
type="button">
{{action.label}}
</button>
<i
v-show="action.isLoading"
class="fa fa-spin fa-spinner"
aria-hidden="true">
</i>
</button>
</template>
</section>
</header>
......
......@@ -83,7 +83,7 @@ export default {
} else {
commitAuthorInformation = {
avatar_url: this.pipeline.commit.author_gravatar_url,
web_url: `mailto:${this.pipeline.commit.author_email}`,
path: `mailto:${this.pipeline.commit.author_email}`,
username: this.pipeline.commit.author_name,
};
}
......
......@@ -60,6 +60,12 @@ export default {
avatarSizeClass() {
return `s${this.size}`;
},
// API response sends null when gravatar is disabled and
// we provide an empty string when we use it inside user avatar link.
// In both cases we should render the defaultAvatarUrl
imageSource() {
return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
},
},
};
</script>
......@@ -68,7 +74,7 @@ export default {
<img
class="avatar"
:class="[avatarSizeClass, cssClasses]"
:src="imgSrc"
:src="imageSource"
:width="size"
:height="size"
:alt="imgAlt"
......
......@@ -984,3 +984,11 @@
width: 12px;
}
}
.pipeline-header-container {
min-height: 55px;
.text-center {
padding-top: 12px;
}
}
class UserEntity < API::Entities::UserBasic
include RequestAwareEntity
expose :path do |user|
user_path(user)
end
end
.page-content-header
.header-main-content
= render 'ci/status/badge', status: @pipeline.detailed_status(current_user), title: @pipeline.status_title
%strong Pipeline ##{@pipeline.id}
triggered #{time_ago_with_tooltip(@pipeline.created_at)}
- if @pipeline.user
by
= user_avatar(user: @pipeline.user, size: 24)
= user_link(@pipeline.user)
.header-action-buttons
- if can?(current_user, :update_pipeline, @pipeline.project)
- if @pipeline.retryable?
= link_to "Retry", retry_namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'js-retry-button btn btn-inverted-secondary', method: :post
- if @pipeline.cancelable?
= link_to "Cancel running", cancel_namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post
#js-pipeline-header-vue.pipeline-header-container
- if @commit
.commit-box
......
---
title: Makes header information of pipeline show page realtine
merge_request:
author:
......@@ -76,7 +76,7 @@ describe 'Commits' do
end
end
describe 'Commit builds' do
describe 'Commit builds', :feature, :js do
before do
visit ci_status_path(pipeline)
end
......@@ -85,7 +85,6 @@ describe 'Commits' do
expect(page).to have_content pipeline.sha[0..7]
expect(page).to have_content pipeline.git_commit_message
expect(page).to have_content pipeline.user.name
expect(page).to have_content pipeline.created_at.strftime('%b %d, %Y')
end
end
......@@ -102,7 +101,7 @@ describe 'Commits' do
end
describe 'Cancel all builds' do
it 'cancels commit' do
it 'cancels commit', :js do
visit ci_status_path(pipeline)
click_on 'Cancel running'
expect(page).to have_content 'canceled'
......@@ -110,9 +109,9 @@ describe 'Commits' do
end
describe 'Cancel build' do
it 'cancels build' do
it 'cancels build', :js do
visit ci_status_path(pipeline)
find('a.btn[title="Cancel"]').click
find('.js-btn-cancel-pipeline').click
expect(page).to have_content 'canceled'
end
end
......@@ -152,17 +151,20 @@ describe 'Commits' do
visit ci_status_path(pipeline)
end
it do
it 'Renders header', :feature, :js do
expect(page).to have_content pipeline.sha[0..7]
expect(page).to have_content pipeline.git_commit_message
expect(page).to have_content pipeline.user.name
expect(page).to have_link('Download artifacts')
expect(page).not_to have_link('Cancel running')
expect(page).not_to have_link('Retry')
end
it do
expect(page).to have_link('Download artifacts')
end
end
context 'when accessing internal project with disallowed access' do
context 'when accessing internal project with disallowed access', :feature, :js do
before do
project.update(
visibility_level: Gitlab::VisibilityLevel::INTERNAL,
......@@ -175,7 +177,7 @@ describe 'Commits' do
expect(page).to have_content pipeline.sha[0..7]
expect(page).to have_content pipeline.git_commit_message
expect(page).to have_content pipeline.user.name
expect(page).not_to have_link('Download artifacts')
expect(page).not_to have_link('Cancel running')
expect(page).not_to have_link('Retry')
end
......
......@@ -229,7 +229,6 @@ describe 'Pipeline', :feature, :js do
before { find('.js-retry-button').trigger('click') }
it { expect(page).not_to have_content('Retry') }
it { expect(page).to have_selector('.retried') }
end
end
......@@ -240,7 +239,6 @@ describe 'Pipeline', :feature, :js do
before { click_on 'Cancel running' }
it { expect(page).not_to have_content('Cancel running') }
it { expect(page).to have_selector('.ci-canceled') }
end
end
......
import Vue from 'vue';
import headerComponent from '~/pipelines/components/header_component.vue';
import eventHub from '~/pipelines/event_hub';
describe('Pipeline details header', () => {
let HeaderComponent;
let vm;
let props;
beforeEach(() => {
HeaderComponent = Vue.extend(headerComponent);
props = {
pipeline: {
details: {
status: {
group: 'failed',
icon: 'ci-status-failed',
label: 'failed',
text: 'failed',
details_path: 'path',
},
},
id: 123,
created_at: '2017-05-08T14:57:39.781Z',
user: {
web_url: 'path',
name: 'Foo',
username: 'foobar',
email: 'foo@bar.com',
avatar_url: 'link',
},
retry_path: 'path',
},
isLoading: false,
};
vm = new HeaderComponent({ propsData: props }).$mount();
});
afterEach(() => {
vm.$destroy();
});
it('should render provided pipeline info', () => {
expect(
vm.$el.querySelector('.header-main-content').textContent.replace(/\s+/g, ' ').trim(),
).toEqual('failed Pipeline #123 triggered 3 weeks ago by Foo');
});
describe('action buttons', () => {
it('should call postAction when button action is clicked', () => {
eventHub.$on('headerPostAction', (action) => {
expect(action.path).toEqual('path');
});
vm.$el.querySelector('button').click();
});
});
});
import Vue from 'vue';
import PipelineMediator from '~/pipelines/pipeline_details_mediatior';
describe('PipelineMdediator', () => {
let mediator;
beforeEach(() => {
mediator = new PipelineMediator({ endpoint: 'foo' });
});
it('should set defaults', () => {
expect(mediator.options).toEqual({ endpoint: 'foo' });
expect(mediator.state.isLoading).toEqual(false);
expect(mediator.store).toBeDefined();
expect(mediator.service).toBeDefined();
});
describe('request and store data', () => {
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify({ foo: 'bar' }), {
status: 200,
}));
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptor, interceptor);
});
it('should store received data', (done) => {
mediator.fetchPipeline();
setTimeout(() => {
expect(mediator.store.state.pipeline).toEqual({ foo: 'bar' });
done();
});
});
});
});
import PipelineStore from '~/pipelines/stores/pipeline_store';
describe('Pipeline Store', () => {
let store;
beforeEach(() => {
store = new PipelineStore();
});
it('should set defaults', () => {
expect(store.state).toEqual({ pipeline: {} });
expect(store.state.pipeline).toEqual({});
});
describe('storePipeline', () => {
it('should store empty object if none is provided', () => {
store.storePipeline();
expect(store.state.pipeline).toEqual({});
});
it('should store received object', () => {
store.storePipeline({ foo: 'bar' });
expect(store.state.pipeline).toEqual({ foo: 'bar' });
});
});
});
......@@ -47,6 +47,7 @@ describe('Pipeline Url Component', () => {
web_url: '/',
name: 'foo',
avatar_url: '/',
path: '/',
},
},
};
......
......@@ -24,6 +24,7 @@ describe('Commit component', () => {
author: {
avatar_url: 'https://gitlab.com/uploads/user/avatar/300478/avatar.png',
web_url: 'https://gitlab.com/jschatz1',
path: '/jschatz1',
username: 'jschatz1',
},
},
......@@ -46,6 +47,7 @@ describe('Commit component', () => {
author: {
avatar_url: 'https://gitlab.com/uploads/user/avatar/300478/avatar.png',
web_url: 'https://gitlab.com/jschatz1',
path: '/jschatz1',
username: 'jschatz1',
},
commitIconSvg: '<svg></svg>',
......@@ -81,7 +83,7 @@ describe('Commit component', () => {
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);
).toEqual(props.author.path);
});
it('Should render the author avatar with title and alt attributes', () => {
......
......@@ -33,12 +33,14 @@ describe('Header CI Component', () => {
path: 'path',
type: 'button',
cssClass: 'btn',
isLoading: false,
},
{
label: 'Go',
path: 'path',
type: 'link',
cssClass: 'link',
isLoading: false,
},
],
};
......@@ -79,4 +81,13 @@ describe('Header CI Component', () => {
expect(vm.$el.querySelector('.link').textContent.trim()).toEqual(props.actions[1].label);
expect(vm.$el.querySelector('.link').getAttribute('href')).toEqual(props.actions[0].path);
});
it('should show loading icon', (done) => {
vm.actions[0].isLoading = true;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.btn .fa-spinner').getAttribute('style')).toEqual('');
done();
});
});
});
......@@ -76,7 +76,7 @@ describe('Pipelines Table Row', () => {
it('should render user information', () => {
expect(
component.$el.querySelector('td:nth-child(2) a:nth-child(3)').getAttribute('href'),
).toEqual(pipeline.user.web_url);
).toEqual(pipeline.user.path);
expect(
component.$el.querySelector('td:nth-child(2) img').getAttribute('data-original-title'),
......@@ -120,7 +120,7 @@ describe('Pipelines Table Row', () => {
component = buildComponent(pipeline);
const { commitAuthorLink, commitAuthorName } = findElements();
expect(commitAuthorLink).toEqual(pipeline.commit.author.web_url);
expect(commitAuthorLink).toEqual(pipeline.commit.author.path);
expect(commitAuthorName).toEqual(pipeline.commit.author.username);
});
......
require 'spec_helper'
describe UserEntity do
include Gitlab::Routing
let(:entity) { described_class.new(user) }
let(:user) { create(:user) }
subject { entity.as_json }
......@@ -20,4 +22,8 @@ describe UserEntity do
it 'does not expose 2FA OTPs' do
expect(subject).not_to include(/otp/)
end
it 'exposes user path' do
expect(subject[:path]).to eq user_path(user)
end
end
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