Commit 5f3c90cd authored by Phil Hughes's avatar Phil Hughes

Merge branch '50904-empty-states' into 'master'

Renders empty states in the Vue app in Job page

See merge request gitlab-org/gitlab-ce!22087
parents 2090f2d0 e13baff0
...@@ -27,7 +27,7 @@ ...@@ -27,7 +27,7 @@
value === null || value === null ||
(Object.prototype.hasOwnProperty.call(value, 'path') && (Object.prototype.hasOwnProperty.call(value, 'path') &&
Object.prototype.hasOwnProperty.call(value, 'method') && Object.prototype.hasOwnProperty.call(value, 'method') &&
Object.prototype.hasOwnProperty.call(value, 'title')) Object.prototype.hasOwnProperty.call(value, 'button_title'))
); );
}, },
}, },
...@@ -67,7 +67,7 @@ ...@@ -67,7 +67,7 @@
:data-method="action.method" :data-method="action.method"
class="js-job-empty-state-action btn btn-primary" class="js-job-empty-state-action btn btn-primary"
> >
{{ action.title }} {{ action.button_title }}
</a> </a>
</div> </div>
</div> </div>
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { mapGetters, mapState } from 'vuex'; import { mapGetters, mapState } from 'vuex';
import CiHeader from '~/vue_shared/components/header_ci_component.vue'; import CiHeader from '~/vue_shared/components/header_ci_component.vue';
import Callout from '~/vue_shared/components/callout.vue'; import Callout from '~/vue_shared/components/callout.vue';
import EmptyState from './empty_state.vue';
import EnvironmentsBlock from './environments_block.vue'; import EnvironmentsBlock from './environments_block.vue';
import ErasedBlock from './erased_block.vue'; import ErasedBlock from './erased_block.vue';
import StuckBlock from './stuck_block.vue'; import StuckBlock from './stuck_block.vue';
...@@ -11,6 +12,7 @@ ...@@ -11,6 +12,7 @@
components: { components: {
CiHeader, CiHeader,
Callout, Callout,
EmptyState,
EnvironmentsBlock, EnvironmentsBlock,
ErasedBlock, ErasedBlock,
StuckBlock, StuckBlock,
...@@ -31,6 +33,8 @@ ...@@ -31,6 +33,8 @@
'jobHasStarted', 'jobHasStarted',
'hasEnvironment', 'hasEnvironment',
'isJobStuck', 'isJobStuck',
'hasTrace',
'emptyStateIllustration',
]), ]),
}, },
}; };
...@@ -77,12 +81,14 @@ ...@@ -77,12 +81,14 @@
<environments-block <environments-block
v-if="hasEnvironment" v-if="hasEnvironment"
class="js-job-environment"
:deployment-status="job.deployment_status" :deployment-status="job.deployment_status"
:icon-status="job.status" :icon-status="job.status"
/> />
<erased-block <erased-block
v-if="job.erased" v-if="job.erased"
class="js-job-erased"
:user="job.erased_by" :user="job.erased_by"
:erased-at="job.erased_at" :erased-at="job.erased_at"
/> />
...@@ -91,6 +97,15 @@ ...@@ -91,6 +97,15 @@
<!-- EO job log --> <!-- EO job log -->
<!--empty state --> <!--empty state -->
<empty-state
v-if="!hasTrace"
class="js-job-empty-state"
:illustration-path="emptyStateIllustration.image"
:illustration-size-class="emptyStateIllustration.size"
:title="emptyStateIllustration.title"
:content="emptyStateIllustration.content"
:action="job.status.action"
/>
<!-- EO empty state --> <!-- EO empty state -->
<!-- EO Body Section --> <!-- EO Body Section -->
......
...@@ -29,6 +29,16 @@ export const jobHasStarted = state => !(state.job.started === false); ...@@ -29,6 +29,16 @@ export const jobHasStarted = state => !(state.job.started === false);
export const hasEnvironment = state => !_.isEmpty(state.job.deployment_status); export const hasEnvironment = state => !_.isEmpty(state.job.deployment_status);
/**
* Checks if it the job has trace.
* Used to check if it should render the job log or the empty state
* @returns {Boolean}
*/
export const hasTrace = state => state.job.has_trace || state.job.status.group === 'running';
export const emptyStateIllustration = state =>
(state.job && state.job.status && state.job.status.illustration) || {};
/** /**
* When the job is pending and there are no available runners * When the job is pending and there are no available runners
* we need to render the stuck block; * we need to render the stuck block;
...@@ -36,7 +46,8 @@ export const hasEnvironment = state => !_.isEmpty(state.job.deployment_status); ...@@ -36,7 +46,8 @@ export const hasEnvironment = state => !_.isEmpty(state.job.deployment_status);
* @returns {Boolean} * @returns {Boolean}
*/ */
export const isJobStuck = state => export const isJobStuck = state =>
state.job.status.group === 'pending' && state.job.runners && state.job.runners.available === false; state.job.status.group === 'pending' &&
(!_.isEmpty(state.job.runners) && state.job.runners.available === false);
// prevent babel-plugin-rewire from generating an invalid default during karma tests // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
...@@ -10,7 +10,12 @@ class DetailedStatusEntity < Grape::Entity ...@@ -10,7 +10,12 @@ class DetailedStatusEntity < Grape::Entity
expose :illustration do |status| expose :illustration do |status|
begin begin
status.illustration illustration = {
image: ActionController::Base.helpers.image_path(status.illustration[:image])
}
illustration = status.illustration.merge(illustration)
illustration
rescue NotImplementedError rescue NotImplementedError
# ignored # ignored
end end
...@@ -25,5 +30,6 @@ class DetailedStatusEntity < Grape::Entity ...@@ -25,5 +30,6 @@ class DetailedStatusEntity < Grape::Entity
expose :action_title, as: :title expose :action_title, as: :title
expose :action_path, as: :path expose :action_path, as: :path
expose :action_method, as: :method expose :action_method, as: :method
expose :action_button_title, as: :button_title
end end
end end
- illustration = local_assigns.fetch(:illustration)
- illustration_size = local_assigns.fetch(:illustration_size)
- title = local_assigns.fetch(:title)
- content = local_assigns.fetch(:content, nil)
- action = local_assigns.fetch(:action, nil)
.row.empty-state
.col-12
.svg-content{ class: illustration_size }
= image_tag illustration
.col-12
.text-content
%h4.text-center= title
- if content
%p= content
- if action
.text-center
= action
- detailed_status = @build.detailed_status(current_user)
- illustration = detailed_status.illustration
= render 'empty_state',
illustration: illustration[:image],
illustration_size: illustration[:size],
title: illustration[:title],
content: illustration[:content],
action: detailed_status.has_action? ? link_to(detailed_status.action_button_title, detailed_status.action_path, method: detailed_status.action_method, class: 'btn btn-primary', title: detailed_status.action_button_title) : nil
...@@ -42,8 +42,6 @@ ...@@ -42,8 +42,6 @@
= custom_icon('scroll_down') = custom_icon('scroll_down')
= render 'shared/builds/build_output' = render 'shared/builds/build_output'
- else
= render "empty_states"
#js-details-block-vue{ data: { terminal_path: can?(current_user, :create_build_terminal, @build) && @build.has_terminal? ? terminal_project_job_path(@project, @build) : nil } } #js-details-block-vue{ data: { terminal_path: can?(current_user, :create_build_terminal, @build) && @build.has_terminal? ? terminal_project_job_path(@project, @build) : nil } }
......
...@@ -542,7 +542,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do ...@@ -542,7 +542,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do
visit project_job_path(project, job) visit project_job_path(project, job)
end end
it 'shows manual action empty state' do it 'shows manual action empty state', :js do
expect(page).to have_content(job.detailed_status(user).illustration[:title]) expect(page).to have_content(job.detailed_status(user).illustration[:title])
expect(page).to have_content('This job requires a manual action') expect(page).to have_content('This job requires a manual action')
expect(page).to have_content('This job depends on a user to trigger its process. Often they are used to deploy code to production environments') expect(page).to have_content('This job depends on a user to trigger its process. Often they are used to deploy code to production environments')
...@@ -566,14 +566,14 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do ...@@ -566,14 +566,14 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do
visit project_job_path(project, job) visit project_job_path(project, job)
end end
it 'shows empty state' do it 'shows empty state', :js do
expect(page).to have_content(job.detailed_status(user).illustration[:title]) expect(page).to have_content(job.detailed_status(user).illustration[:title])
expect(page).to have_content('This job has not been triggered yet') expect(page).to have_content('This job has not been triggered yet')
expect(page).to have_content('This job depends on upstream jobs that need to succeed in order for this job to be triggered') expect(page).to have_content('This job depends on upstream jobs that need to succeed in order for this job to be triggered')
end end
end end
context 'Pending job' do context 'Pending job', :js do
let(:job) { create(:ci_build, :pending, pipeline: pipeline) } let(:job) { create(:ci_build, :pending, pipeline: pipeline) }
before do before do
...@@ -600,7 +600,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do ...@@ -600,7 +600,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do
end end
end end
context 'without log' do context 'without log', :js do
let(:job) { create(:ci_build, :canceled, pipeline: pipeline) } let(:job) { create(:ci_build, :canceled, pipeline: pipeline) }
before do before do
...@@ -615,7 +615,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do ...@@ -615,7 +615,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do
end end
end end
context 'Skipped job' do context 'Skipped job', :js do
let(:job) { create(:ci_build, :skipped, pipeline: pipeline) } let(:job) { create(:ci_build, :skipped, pipeline: pipeline) }
before do before do
...@@ -629,7 +629,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do ...@@ -629,7 +629,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do
end end
end end
context 'when job is failed but has no trace' do context 'when job is failed but has no trace', :js do
let(:job) { create(:ci_build, :failed, pipeline: pipeline) } let(:job) { create(:ci_build, :failed, pipeline: pipeline) }
it 'renders empty state' do it 'renders empty state' do
......
...@@ -67,7 +67,7 @@ describe('Empty State', () => { ...@@ -67,7 +67,7 @@ describe('Empty State', () => {
content, content,
action: { action: {
path: 'runner', path: 'runner',
title: 'Check runner', button_title: 'Check runner',
method: 'post', method: 'post',
}, },
}); });
......
...@@ -37,6 +37,7 @@ describe('Job App ', () => { ...@@ -37,6 +37,7 @@ describe('Job App ', () => {
available: false, available: false,
}, },
tags: ['docker'], tags: ['docker'],
has_trace: true,
}; };
const props = { const props = {
...@@ -182,4 +183,142 @@ describe('Job App ', () => { ...@@ -182,4 +183,142 @@ describe('Job App ', () => {
expect(vm.$el.querySelector('.js-job-stuck')).toBeNull(); expect(vm.$el.querySelector('.js-job-stuck')).toBeNull();
}); });
}); });
describe('environments block', () => {
it('renders environment block when job has environment', () => {
store.dispatch(
'receiveJobSuccess',
Object.assign({}, job, {
deployment_status: {
environment: {
environment_path: '/path',
name: 'foo',
},
},
}),
);
vm = mountComponentWithStore(Component, {
props,
store,
});
expect(vm.$el.querySelector('.js-job-environment')).not.toBeNull();
});
it('does not render environment block when job has environment', () => {
store.dispatch('receiveJobSuccess', job);
vm = mountComponentWithStore(Component, {
props,
store,
});
expect(vm.$el.querySelector('.js-job-environment')).toBeNull();
});
});
describe('erased block', () => {
it('renders erased block when `erased` is true', () => {
store.dispatch(
'receiveJobSuccess',
Object.assign({}, job, {
erased: true,
erased_by: {
username: 'root',
web_url: 'gitlab.com/root',
},
erased_at: '2016-11-07T11:11:16.525Z',
}),
);
vm = mountComponentWithStore(Component, {
props,
store,
});
expect(vm.$el.querySelector('.js-job-erased')).not.toBeNull();
});
it('does not render erased block when `erased` is false', () => {
store.dispatch('receiveJobSuccess', Object.assign({}, job, { erased: false }));
vm = mountComponentWithStore(Component, {
props,
store,
});
expect(vm.$el.querySelector('.js-job-erased')).toBeNull();
});
});
describe('empty states block', () => {
it('renders empty state when job does not have trace and is not running', () => {
store.dispatch(
'receiveJobSuccess',
Object.assign({}, job, {
has_trace: false,
status: {
group: 'pending',
icon: 'status_pending',
label: 'pending',
text: 'pending',
details_path: 'path',
illustration: {
image: 'path',
size: '340',
title: 'Empty State',
content: 'This is an empty state',
},
action: {
button_title: 'Retry job',
method: 'post',
path: '/path',
},
},
}),
);
vm = mountComponentWithStore(Component, {
props,
store,
});
expect(vm.$el.querySelector('.js-job-empty-state')).not.toBeNull();
});
it('does not render empty state when job does not have trace but it is running', () => {
store.dispatch(
'receiveJobSuccess',
Object.assign({}, job, {
has_trace: false,
status: {
group: 'running',
icon: 'status_running',
label: 'running',
text: 'running',
details_path: 'path',
},
}),
);
vm = mountComponentWithStore(Component, {
props,
store,
});
expect(vm.$el.querySelector('.js-job-empty-state')).toBeNull();
});
it('does not render empty state when job has trace but it is not running', () => {
store.dispatch('receiveJobSuccess', Object.assign({}, job, { has_trace: true }));
vm = mountComponentWithStore(Component, {
props,
store,
});
expect(vm.$el.querySelector('.js-job-empty-state')).toBeNull();
});
});
}); });
...@@ -99,12 +99,14 @@ describe('Job Store Getters', () => { ...@@ -99,12 +99,14 @@ describe('Job Store Getters', () => {
expect(getters.hasEnvironment(localState)).toEqual(false); expect(getters.hasEnvironment(localState)).toEqual(false);
}); });
}); });
describe('with an empty object for `deployment_status`', () => { describe('with an empty object for `deployment_status`', () => {
it('returns false', () => { it('returns false', () => {
localState.job.deployment_status = {}; localState.job.deployment_status = {};
expect(getters.hasEnvironment(localState)).toEqual(false); expect(getters.hasEnvironment(localState)).toEqual(false);
}); });
}); });
describe('when `deployment_status` is defined and not empty', () => { describe('when `deployment_status` is defined and not empty', () => {
it('returns true', () => { it('returns true', () => {
localState.job.deployment_status = { localState.job.deployment_status = {
...@@ -118,4 +120,94 @@ describe('Job Store Getters', () => { ...@@ -118,4 +120,94 @@ describe('Job Store Getters', () => {
}); });
}); });
}); });
describe('hasTrace', () => {
describe('when has_trace is true', () => {
it('returns true', () => {
localState.job.has_trace = true;
localState.job.status = {};
expect(getters.hasTrace(localState)).toEqual(true);
});
});
describe('when job is running', () => {
it('returns true', () => {
localState.job.has_trace = false;
localState.job.status = { group: 'running' };
expect(getters.hasTrace(localState)).toEqual(true);
});
});
describe('when has_trace is false and job is not running', () => {
it('returns false', () => {
localState.job.has_trace = false;
localState.job.status = { group: 'pending' };
expect(getters.hasTrace(localState)).toEqual(false);
});
});
});
describe('emptyStateIllustration', () => {
describe('with defined illustration', () => {
it('returns the state illustration object', () => {
localState.job.status = {
illustration: {
path: 'foo',
},
};
expect(getters.emptyStateIllustration(localState)).toEqual({ path: 'foo' });
});
});
describe('when illustration is not defined', () => {
it('returns an empty object', () => {
expect(getters.emptyStateIllustration(localState)).toEqual({});
});
});
});
describe('isJobStuck', () => {
describe('when job is pending and runners are not available', () => {
it('returns true', () => {
localState.job.status = {
group: 'pending',
};
localState.job.runners = {
available: false,
};
expect(getters.isJobStuck(localState)).toEqual(true);
});
});
describe('when job is not pending', () => {
it('returns false', () => {
localState.job.status = {
group: 'running',
};
localState.job.runners = {
available: false,
};
expect(getters.isJobStuck(localState)).toEqual(false);
});
});
describe('when runners are available', () => {
it('returns false', () => {
localState.job.status = {
group: 'pending',
};
localState.job.runners = {
available: true,
};
expect(getters.isJobStuck(localState)).toEqual(false);
});
});
});
}); });
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