Commit 60d95d8f authored by Phil Hughes's avatar Phil Hughes

Merge branch '38587-pipelines-empty-state' into 'master'

Resolve "Pipelines view should handle empty state and buttons properly"

Closes #38587

See merge request gitlab-org/gitlab-ce!17433
parents a2804512 e3251a40
...@@ -20,10 +20,6 @@ ...@@ -20,10 +20,6 @@
type: String, type: String,
required: true, required: true,
}, },
emptyStateSvgPath: {
type: String,
required: true,
},
errorStateSvgPath: { errorStateSvgPath: {
type: String, type: String,
required: true, required: true,
...@@ -45,23 +41,14 @@ ...@@ -45,23 +41,14 @@
}, },
computed: { computed: {
/**
* Empty state is only rendered if after the first request we receive no pipelines.
*
* @return {Boolean}
*/
shouldRenderEmptyState() {
return !this.state.pipelines.length &&
!this.isLoading &&
this.hasMadeRequest &&
!this.hasError;
},
shouldRenderTable() { shouldRenderTable() {
return !this.isLoading && return !this.isLoading &&
this.state.pipelines.length > 0 && this.state.pipelines.length > 0 &&
!this.hasError; !this.hasError;
}, },
shouldRenderErrorState() {
return this.hasError && !this.isLoading;
},
}, },
created() { created() {
this.service = new PipelinesService(this.endpoint); this.service = new PipelinesService(this.endpoint);
...@@ -92,25 +79,22 @@ ...@@ -92,25 +79,22 @@
<div class="content-list pipelines"> <div class="content-list pipelines">
<loading-icon <loading-icon
label="Loading pipelines" :label="s__('Pipelines|Loading Pipelines')"
size="3" size="3"
v-if="isLoading" v-if="isLoading"
class="prepend-top-20"
/> />
<empty-state <svg-blank-state
v-if="shouldRenderEmptyState" v-else-if="shouldRenderErrorState"
:help-page-path="helpPagePath" :svg-path="errorStateSvgPath"
:empty-state-svg-path="emptyStateSvgPath" :message="s__(`Pipelines|There was an error fetching the pipelines.
/> Try again in a few moments or contact your support team.`)"
<error-state
v-if="shouldRenderErrorState"
:error-state-svg-path="errorStateSvgPath"
/> />
<div <div
class="table-holder" class="table-holder"
v-if="shouldRenderTable" v-else-if="shouldRenderTable"
> >
<pipelines-table-component <pipelines-table-component
:pipelines="state.pipelines" :pipelines="state.pipelines"
......
...@@ -2,6 +2,7 @@ import Vue from 'vue'; ...@@ -2,6 +2,7 @@ import Vue from 'vue';
import PipelinesStore from '../../../../pipelines/stores/pipelines_store'; import PipelinesStore from '../../../../pipelines/stores/pipelines_store';
import pipelinesComponent from '../../../../pipelines/components/pipelines.vue'; import pipelinesComponent from '../../../../pipelines/components/pipelines.vue';
import Translate from '../../../../vue_shared/translate'; import Translate from '../../../../vue_shared/translate';
import { convertPermissionToBoolean } from '../../../../lib/utils/common_utils';
Vue.use(Translate); Vue.use(Translate);
...@@ -11,16 +12,28 @@ document.addEventListener('DOMContentLoaded', () => new Vue({ ...@@ -11,16 +12,28 @@ document.addEventListener('DOMContentLoaded', () => new Vue({
pipelinesComponent, pipelinesComponent,
}, },
data() { data() {
const store = new PipelinesStore();
return { return {
store, store: new PipelinesStore(),
}; };
}, },
created() {
this.dataset = document.querySelector(this.$options.el).dataset;
},
render(createElement) { render(createElement) {
return createElement('pipelines-component', { return createElement('pipelines-component', {
props: { props: {
store: this.store, store: this.store,
endpoint: this.dataset.endpoint,
helpPagePath: this.dataset.helpPagePath,
emptyStateSvgPath: this.dataset.emptyStateSvgPath,
errorStateSvgPath: this.dataset.errorStateSvgPath,
noPipelinesSvgPath: this.dataset.noPipelinesSvgPath,
autoDevopsPath: this.dataset.helpAutoDevopsPath,
newPipelinePath: this.dataset.newPipelinePath,
canCreatePipeline: convertPermissionToBoolean(this.dataset.canCreatePipeline),
hasGitlabCi: convertPermissionToBoolean(this.dataset.hasGitlabCi),
ciLintPath: this.dataset.ciLintPath,
resetCachePath: this.dataset.resetCachePath,
}, },
}); });
}, },
......
<script> <script>
export default { export default {
name: 'PipelinesSvgState',
props: { props: {
errorStateSvgPath: { svgPath: {
type: String,
required: true,
},
message: {
type: String, type: String,
required: true, required: true,
}, },
}, },
}; };
</script> </script>
<template> <template>
<div class="row empty-state js-pipelines-error-state"> <div class="row empty-state">
<div class="col-xs-12"> <div class="col-xs-12">
<div class="svg-content"> <div class="svg-content">
<img :src="errorStateSvgPath"/> <img :src="svgPath" />
</div> </div>
</div> </div>
<div class="col-xs-12 text-center"> <div class="col-xs-12 text-center">
<div class="text-content"> <div class="text-content">
<h4>The API failed to fetch the pipelines.</h4> <h4>{{ message }}</h4>
</div> </div>
</div> </div>
</div> </div>
......
<script> <script>
export default { export default {
name: 'PipelinesEmptyState',
props: { props: {
helpPagePath: { helpPagePath: {
type: String, type: String,
...@@ -9,6 +10,10 @@ ...@@ -9,6 +10,10 @@
type: String, type: String,
required: true, required: true,
}, },
canSetCi: {
type: Boolean,
required: true,
},
}, },
}; };
</script> </script>
...@@ -22,22 +27,36 @@ ...@@ -22,22 +27,36 @@
<div class="col-xs-12"> <div class="col-xs-12">
<div class="text-content"> <div class="text-content">
<template v-if="canSetCi">
<h4 class="text-center"> <h4 class="text-center">
{{ s__("Pipelines|Build with confidence") }} {{ s__('Pipelines|Build with confidence') }}
</h4> </h4>
<p> <p>
{{ s__(`Pipelines|Continous Integration can help {{ s__(`Pipelines|Continous Integration can help
catch bugs by running your tests automatically, catch bugs by running your tests automatically,
while Continuous Deployment can help you deliver code to your product environment.`) }} while Continuous Deployment can help you deliver
code to your product environment.`) }}
</p> </p>
<div class="text-center"> <div class="text-center">
<a <a
:href="helpPagePath" :href="helpPagePath"
class="btn btn-info" class="btn btn-primary js-get-started-pipelines"
> >
{{ s__("Pipelines|Get started with Pipelines") }} {{ s__('Pipelines|Get started with Pipelines') }}
</a> </a>
</div> </div>
</template>
<p
v-else
class="text-center"
>
{{ s__('Pipelines|This project is not currently set up to run pipelines.') }}
</p>
</div> </div>
</div> </div>
</div> </div>
......
<script> <script>
export default { export default {
name: 'PipelineNavControls', name: 'PipelineNavControls',
props: { props: {
newPipelinePath: { newPipelinePath: {
type: String, type: String,
required: true, required: false,
}, default: null,
hasCiEnabled: {
type: Boolean,
required: true,
},
helpPagePath: {
type: String,
required: true,
}, },
resetCachePath: { resetCachePath: {
type: String, type: String,
required: true, required: false,
default: null,
}, },
ciLintPath: { ciLintPath: {
type: String, type: String,
required: true, required: false,
}, default: null,
canCreatePipeline: {
type: Boolean,
required: true,
}, },
}, },
}; };
</script> </script>
<template> <template>
<div class="nav-controls"> <div class="nav-controls">
<a <a
v-if="canCreatePipeline" v-if="newPipelinePath"
:href="newPipelinePath" :href="newPipelinePath"
class="btn btn-create"> class="btn btn-create js-run-pipeline"
Run Pipeline >
</a> {{ s__('Pipelines|Run Pipeline') }}
<a
v-if="!hasCiEnabled"
:href="helpPagePath"
class="btn btn-info">
Get started with Pipelines
</a> </a>
<a <a
v-if="resetCachePath"
data-method="post" data-method="post"
rel="nofollow"
:href="resetCachePath" :href="resetCachePath"
class="btn btn-default"> class="btn btn-default js-clear-cache"
Clear runner caches >
{{ s__('Pipelines|Clear Runner Caches') }}
</a> </a>
<a <a
v-if="ciLintPath"
:href="ciLintPath" :href="ciLintPath"
class="btn btn-default"> class="btn btn-default js-ci-lint"
CI Lint >
{{ s__('Pipelines|CI Lint') }}
</a> </a>
</div> </div>
</template> </template>
import Visibility from 'visibilityjs'; import Visibility from 'visibilityjs';
import { __ } from '../../locale';
import Flash from '../../flash'; import Flash from '../../flash';
import Poll from '../../lib/utils/poll'; import Poll from '../../lib/utils/poll';
import emptyState from '../components/empty_state.vue'; import EmptyState from '../components/empty_state.vue';
import errorState from '../components/error_state.vue'; import SvgBlankState from '../components/blank_state.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import LoadingIcon from '../../vue_shared/components/loading_icon.vue';
import pipelinesTableComponent from '../components/pipelines_table.vue'; import PipelinesTableComponent from '../components/pipelines_table.vue';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
export default { export default {
components: { components: {
pipelinesTableComponent, PipelinesTableComponent,
errorState, SvgBlankState,
emptyState, EmptyState,
loadingIcon, LoadingIcon,
},
computed: {
shouldRenderErrorState() {
return this.hasError && !this.isLoading;
},
}, },
data() { data() {
return { return {
...@@ -85,6 +81,7 @@ export default { ...@@ -85,6 +81,7 @@ export default {
this.hasError = true; this.hasError = true;
this.isLoading = false; this.isLoading = false;
this.updateGraphDropdown = false; this.updateGraphDropdown = false;
this.hasMadeRequest = true;
}, },
setIsMakingRequest(isMakingRequest) { setIsMakingRequest(isMakingRequest) {
this.isMakingRequest = isMakingRequest; this.isMakingRequest = isMakingRequest;
...@@ -96,7 +93,7 @@ export default { ...@@ -96,7 +93,7 @@ export default {
postAction(endpoint) { postAction(endpoint) {
this.service.postAction(endpoint) this.service.postAction(endpoint)
.then(() => eventHub.$emit('refreshPipelines')) .then(() => eventHub.$emit('refreshPipelines'))
.catch(() => new Flash('An error occurred while making the request.')); .catch(() => Flash(__('An error occurred while making the request.')));
}, },
}, },
}; };
...@@ -7,8 +7,9 @@ ...@@ -7,8 +7,9 @@
"help-auto-devops-path" => help_page_path('topics/autodevops/index.md'), "help-auto-devops-path" => help_page_path('topics/autodevops/index.md'),
"empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'), "empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'),
"error-state-svg-path" => image_path('illustrations/pipelines_failed.svg'), "error-state-svg-path" => image_path('illustrations/pipelines_failed.svg'),
"new-pipeline-path" => new_project_pipeline_path(@project), "no-pipelines-svg-path" => image_path('illustrations/pipelines_pending.svg'),
"can-create-pipeline" => can?(current_user, :create_pipeline, @project).to_s, "can-create-pipeline" => can?(current_user, :create_pipeline, @project).to_s,
"has-ci" => @repository.gitlab_ci_yml, "new-pipeline-path" => can?(current_user, :create_pipeline, @project) && new_project_pipeline_path(@project),
"ci-lint-path" => ci_lint_path, "ci-lint-path" => can?(current_user, :create_pipeline, @project) && ci_lint_path,
"reset-cache-path" => reset_cache_project_settings_ci_cd_path(@project) } } "reset-cache-path" => can?(current_user, :admin_pipeline, @project) && reset_cache_project_settings_ci_cd_path(@project) ,
"has-gitlab-ci" => (@project.has_ci? && @project.builds_enabled?).to_s } }
---
title: Handle empty state in Pipelines page
merge_request:
author:
type: fixed
...@@ -86,7 +86,22 @@ describe 'Pipelines', :js do ...@@ -86,7 +86,22 @@ describe 'Pipelines', :js do
it 'updates content when tab is clicked' do it 'updates content when tab is clicked' do
page.find('.js-pipelines-tab-pending').click page.find('.js-pipelines-tab-pending').click
wait_for_requests wait_for_requests
expect(page).to have_content('No pipelines to show.') expect(page).to have_content('There are currently no pending pipelines.')
end
end
context 'navigation links' do
before do
visit project_pipelines_path(project)
wait_for_requests
end
it 'renders run pipeline link' do
expect(page).to have_link('Run Pipeline')
end
it 'renders ci lint link' do
expect(page).to have_link('CI Lint')
end end
end end
...@@ -542,7 +557,7 @@ describe 'Pipelines', :js do ...@@ -542,7 +557,7 @@ describe 'Pipelines', :js do
end end
it 'has a clear caches button' do it 'has a clear caches button' do
expect(page).to have_link 'Clear runner caches' expect(page).to have_link 'Clear Runner Caches'
end end
describe 'user clicks the button' do describe 'user clicks the button' do
...@@ -552,19 +567,31 @@ describe 'Pipelines', :js do ...@@ -552,19 +567,31 @@ describe 'Pipelines', :js do
end end
it 'increments jobs_cache_index' do it 'increments jobs_cache_index' do
click_link 'Clear runner caches' click_link 'Clear Runner Caches'
expect(page.find('.flash-notice')).to have_content 'Project cache successfully reset.' expect(page.find('.flash-notice')).to have_content 'Project cache successfully reset.'
end end
end end
context 'when project does not have jobs_cache_index' do context 'when project does not have jobs_cache_index' do
it 'sets jobs_cache_index to 1' do it 'sets jobs_cache_index to 1' do
click_link 'Clear runner caches' click_link 'Clear Runner Caches'
expect(page.find('.flash-notice')).to have_content 'Project cache successfully reset.' expect(page.find('.flash-notice')).to have_content 'Project cache successfully reset.'
end end
end end
end end
end end
describe 'Empty State' do
let(:project) { create(:project, :repository) }
before do
visit project_pipelines_path(project)
end
it 'renders empty state' do
expect(page).to have_content 'Build with confidence'
end
end
end end
context 'when user is not logged in' do context 'when user is not logged in' do
...@@ -575,7 +602,9 @@ describe 'Pipelines', :js do ...@@ -575,7 +602,9 @@ describe 'Pipelines', :js do
context 'when project is public' do context 'when project is public' do
let(:project) { create(:project, :public, :repository) } let(:project) { create(:project, :public, :repository) }
it { expect(page).to have_content 'Build with confidence' } context 'without pipelines' do
it { expect(page).to have_content 'This project is not currently set up to run pipelines.' }
end
end end
context 'when project is private' do context 'when project is private' do
......
import Vue from 'vue';
import component from '~/pipelines/components/blank_state.vue';
import mountComponent from '../helpers/vue_mount_component_helper';
describe('Pipelines Blank State', () => {
let vm;
let Component;
beforeEach(() => {
Component = Vue.extend(component);
vm = mountComponent(Component,
{
svgPath: 'foo',
message: 'Blank State',
},
);
});
it('should render svg', () => {
expect(vm.$el.querySelector('.svg-content img').getAttribute('src')).toEqual('foo');
});
it('should render message', () => {
expect(
vm.$el.querySelector('h4').textContent.trim(),
).toEqual('Blank State');
});
});
import Vue from 'vue'; import Vue from 'vue';
import emptyStateComp from '~/pipelines/components/empty_state.vue'; import emptyStateComp from '~/pipelines/components/empty_state.vue';
import mountComponent from '../helpers/vue_mount_component_helper';
describe('Pipelines Empty State', () => { describe('Pipelines Empty State', () => {
let component; let component;
...@@ -8,12 +9,15 @@ describe('Pipelines Empty State', () => { ...@@ -8,12 +9,15 @@ describe('Pipelines Empty State', () => {
beforeEach(() => { beforeEach(() => {
EmptyStateComponent = Vue.extend(emptyStateComp); EmptyStateComponent = Vue.extend(emptyStateComp);
component = new EmptyStateComponent({ component = mountComponent(EmptyStateComponent, {
propsData: {
helpPagePath: 'foo', helpPagePath: 'foo',
emptyStateSvgPath: 'foo', emptyStateSvgPath: 'foo',
}, canSetCi: true,
}).$mount(); });
});
afterEach(() => {
component.$destroy();
}); });
it('should render empty state SVG', () => { it('should render empty state SVG', () => {
...@@ -24,16 +28,16 @@ describe('Pipelines Empty State', () => { ...@@ -24,16 +28,16 @@ describe('Pipelines Empty State', () => {
expect(component.$el.querySelector('h4').textContent).toContain('Build with confidence'); expect(component.$el.querySelector('h4').textContent).toContain('Build with confidence');
expect( expect(
component.$el.querySelector('p').textContent.trim().replace(/[\r\n]+/g, ' '), component.$el.querySelector('p').innerHTML.trim().replace(/\n+\s+/m, ' ').replace(/\s\s+/g, ' '),
).toContain('Continous Integration can help catch bugs by running your tests automatically'); ).toContain('Continous Integration can help catch bugs by running your tests automatically,');
expect( expect(
component.$el.querySelector('p').textContent.trim().replace(/[\r\n]+/g, ' '), component.$el.querySelector('p').innerHTML.trim().replace(/\n+\s+/m, ' ').replace(/\s\s+/g, ' '),
).toContain('Continuous Deployment can help you deliver code to your product environment'); ).toContain('while Continuous Deployment can help you deliver code to your product environment');
}); });
it('should render a link with provided help path', () => { it('should render a link with provided help path', () => {
expect(component.$el.querySelector('.btn-info').getAttribute('href')).toEqual('foo'); expect(component.$el.querySelector('.js-get-started-pipelines').getAttribute('href')).toEqual('foo');
expect(component.$el.querySelector('.btn-info').textContent).toContain('Get started with Pipelines'); expect(component.$el.querySelector('.js-get-started-pipelines').textContent).toContain('Get started with Pipelines');
}); });
}); });
import Vue from 'vue';
import errorStateComp from '~/pipelines/components/error_state.vue';
describe('Pipelines Error State', () => {
let component;
let ErrorStateComponent;
beforeEach(() => {
ErrorStateComponent = Vue.extend(errorStateComp);
component = new ErrorStateComponent({
propsData: {
errorStateSvgPath: 'foo',
},
}).$mount();
});
it('should render error state SVG', () => {
expect(component.$el.querySelector('.svg-content svg')).toBeDefined();
});
it('should render emtpy state information', () => {
expect(
component.$el.querySelector('h4').textContent,
).toContain('The API failed to fetch the pipelines');
});
});
import Vue from 'vue'; import Vue from 'vue';
import navControlsComp from '~/pipelines/components/nav_controls.vue'; import navControlsComp from '~/pipelines/components/nav_controls.vue';
import mountComponent from '../helpers/vue_mount_component_helper';
describe('Pipelines Nav Controls', () => { describe('Pipelines Nav Controls', () => {
let NavControlsComponent; let NavControlsComponent;
let component;
beforeEach(() => { beforeEach(() => {
NavControlsComponent = Vue.extend(navControlsComp); NavControlsComponent = Vue.extend(navControlsComp);
}); });
afterEach(() => {
component.$destroy();
});
it('should render link to create a new pipeline', () => { it('should render link to create a new pipeline', () => {
const mockData = { const mockData = {
newPipelinePath: 'foo', newPipelinePath: 'foo',
hasCiEnabled: true,
helpPagePath: 'foo',
ciLintPath: 'foo', ciLintPath: 'foo',
resetCachePath: 'foo', resetCachePath: 'foo',
canCreatePipeline: true,
}; };
const component = new NavControlsComponent({ component = mountComponent(NavControlsComponent, mockData);
propsData: mockData,
}).$mount();
expect(component.$el.querySelector('.btn-create').textContent).toContain('Run Pipeline'); expect(component.$el.querySelector('.js-run-pipeline').textContent).toContain('Run Pipeline');
expect(component.$el.querySelector('.btn-create').getAttribute('href')).toEqual(mockData.newPipelinePath); expect(component.$el.querySelector('.js-run-pipeline').getAttribute('href')).toEqual(mockData.newPipelinePath);
}); });
it('should not render link to create pipeline if no permission is provided', () => { it('should not render link to create pipeline if no path is provided', () => {
const mockData = { const mockData = {
newPipelinePath: 'foo',
hasCiEnabled: true,
helpPagePath: 'foo', helpPagePath: 'foo',
ciLintPath: 'foo', ciLintPath: 'foo',
resetCachePath: 'foo', resetCachePath: 'foo',
canCreatePipeline: false,
}; };
const component = new NavControlsComponent({ component = mountComponent(NavControlsComponent, mockData);
propsData: mockData,
}).$mount();
expect(component.$el.querySelector('.btn-create')).toEqual(null); expect(component.$el.querySelector('.js-run-pipeline')).toEqual(null);
}); });
it('should render link for resetting runner caches', () => { it('should render link for resetting runner caches', () => {
const mockData = { const mockData = {
newPipelinePath: 'foo', newPipelinePath: 'foo',
hasCiEnabled: true,
helpPagePath: 'foo',
ciLintPath: 'foo', ciLintPath: 'foo',
resetCachePath: 'foo', resetCachePath: 'foo',
canCreatePipeline: false,
}; };
const component = new NavControlsComponent({ component = mountComponent(NavControlsComponent, mockData);
propsData: mockData,
}).$mount();
expect(component.$el.querySelectorAll('.btn-default')[0].textContent).toContain('Clear runner caches'); expect(component.$el.querySelector('.js-clear-cache').textContent.trim()).toContain('Clear Runner Caches');
expect(component.$el.querySelectorAll('.btn-default')[0].getAttribute('href')).toEqual(mockData.resetCachePath); expect(component.$el.querySelector('.js-clear-cache').getAttribute('href')).toEqual(mockData.resetCachePath);
}); });
it('should render link for CI lint', () => { it('should render link for CI lint', () => {
const mockData = { const mockData = {
newPipelinePath: 'foo', newPipelinePath: 'foo',
hasCiEnabled: true,
helpPagePath: 'foo',
ciLintPath: 'foo',
resetCachePath: 'foo',
canCreatePipeline: true,
};
const component = new NavControlsComponent({
propsData: mockData,
}).$mount();
expect(component.$el.querySelectorAll('.btn-default')[1].textContent).toContain('CI Lint');
expect(component.$el.querySelectorAll('.btn-default')[1].getAttribute('href')).toEqual(mockData.ciLintPath);
});
it('should render link to help page when CI is not enabled', () => {
const mockData = {
newPipelinePath: 'foo',
hasCiEnabled: false,
helpPagePath: 'foo',
ciLintPath: 'foo',
resetCachePath: 'foo',
canCreatePipeline: true,
};
const component = new NavControlsComponent({
propsData: mockData,
}).$mount();
expect(component.$el.querySelector('.btn-info').textContent).toContain('Get started with Pipelines');
expect(component.$el.querySelector('.btn-info').getAttribute('href')).toEqual(mockData.helpPagePath);
});
it('should not render link to help page when CI is enabled', () => {
const mockData = {
newPipelinePath: 'foo',
hasCiEnabled: true,
helpPagePath: 'foo', helpPagePath: 'foo',
ciLintPath: 'foo', ciLintPath: 'foo',
resetCachePath: 'foo', resetCachePath: 'foo',
canCreatePipeline: true,
}; };
const component = new NavControlsComponent({ component = mountComponent(NavControlsComponent, mockData);
propsData: mockData,
}).$mount();
expect(component.$el.querySelector('.btn-info')).toEqual(null); expect(component.$el.querySelector('.js-ci-lint').textContent.trim()).toContain('CI Lint');
expect(component.$el.querySelector('.js-ci-lint').getAttribute('href')).toEqual(mockData.ciLintPath);
}); });
}); });
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