Merge branch '27574-pipelines-empty-state' into 'master'
Pipelines empty state Closes #27574 See merge request !9978
This commit is contained in:
commit
453d755ae4
22 changed files with 771 additions and 102 deletions
|
@ -1,10 +1,10 @@
|
|||
/* eslint-disable no-new*/
|
||||
/* global Flash */
|
||||
import Vue from 'vue';
|
||||
import PipelinesTableComponent from '../../vue_shared/components/pipelines_table';
|
||||
import PipelinesService from '../../vue_pipelines_index/services/pipelines_service';
|
||||
import PipelineStore from '../../vue_pipelines_index/stores/pipelines_store';
|
||||
import eventHub from '../../vue_pipelines_index/event_hub';
|
||||
import EmptyState from '../../vue_pipelines_index/components/empty_state';
|
||||
import ErrorState from '../../vue_pipelines_index/components/error_state';
|
||||
import '../../lib/utils/common_utils';
|
||||
import '../../vue_shared/vue_resource_interceptor';
|
||||
|
||||
|
@ -22,6 +22,8 @@ import '../../vue_shared/vue_resource_interceptor';
|
|||
export default Vue.component('pipelines-table', {
|
||||
components: {
|
||||
'pipelines-table-component': PipelinesTableComponent,
|
||||
'error-state': ErrorState,
|
||||
'empty-state': EmptyState,
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -36,12 +38,24 @@ export default Vue.component('pipelines-table', {
|
|||
|
||||
return {
|
||||
endpoint: pipelinesTableData.endpoint,
|
||||
helpPagePath: pipelinesTableData.helpPagePath,
|
||||
store,
|
||||
state: store.state,
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
shouldRenderErrorState() {
|
||||
return this.hasError && !this.isLoading;
|
||||
},
|
||||
|
||||
shouldRenderEmptyState() {
|
||||
return !this.state.pipelines.length && !this.isLoading;
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* When the component is about to be mounted, tell the service to fetch the data
|
||||
*
|
||||
|
@ -80,8 +94,8 @@ export default Vue.component('pipelines-table', {
|
|||
this.isLoading = false;
|
||||
})
|
||||
.catch(() => {
|
||||
this.hasError = true;
|
||||
this.isLoading = false;
|
||||
new Flash('An error occurred while fetching the pipelines, please reload the page again.');
|
||||
});
|
||||
},
|
||||
},
|
||||
|
@ -92,12 +106,11 @@ export default Vue.component('pipelines-table', {
|
|||
<i class="fa fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
|
||||
<div class="blank-state blank-state-no-icon"
|
||||
v-if="!isLoading && state.pipelines.length === 0">
|
||||
<h2 class="blank-state-title js-blank-state-title">
|
||||
No pipelines to show
|
||||
</h2>
|
||||
</div>
|
||||
<empty-state
|
||||
v-if="shouldRenderEmptyState"
|
||||
:help-page-path="helpPagePath" />
|
||||
|
||||
<error-state v-if="shouldRenderErrorState" />
|
||||
|
||||
<div class="table-holder"
|
||||
v-if="!isLoading && state.pipelines.length > 0">
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
import pipelinesEmptyStateSVG from 'empty_states/icons/_pipelines_empty.svg';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
helpPagePath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
template: `
|
||||
<div class="row empty-state">
|
||||
<div class="col-xs-12">
|
||||
<div class="svg-content">
|
||||
${pipelinesEmptyStateSVG}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-12 text-center">
|
||||
<div class="text-content">
|
||||
<h4>Build with confidence</h4>
|
||||
<p>
|
||||
Continous Integration can help catch bugs by running your tests automatically,
|
||||
while Continuous Deployment can help you deliver code to your product environment.
|
||||
</p>
|
||||
<a :href="helpPagePath" class="btn btn-info">
|
||||
Get started with Pipelines
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
};
|
|
@ -0,0 +1,19 @@
|
|||
import pipelinesErrorStateSVG from 'empty_states/icons/_pipelines_failed.svg';
|
||||
|
||||
export default {
|
||||
template: `
|
||||
<div class="row empty-state js-pipelines-error-state">
|
||||
<div class="col-xs-12">
|
||||
<div class="svg-content">
|
||||
${pipelinesErrorStateSVG}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-12 text-center">
|
||||
<div class="text-content">
|
||||
<h4>The API failed to fetch the pipelines.</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
};
|
|
@ -0,0 +1,52 @@
|
|||
export default {
|
||||
props: {
|
||||
newPipelinePath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
hasCiEnabled: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
|
||||
helpPagePath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
ciLintPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
canCreatePipeline: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
template: `
|
||||
<div class="nav-controls">
|
||||
<a
|
||||
v-if="canCreatePipeline"
|
||||
:href="newPipelinePath"
|
||||
class="btn btn-create">
|
||||
Run Pipeline
|
||||
</a>
|
||||
|
||||
<a
|
||||
v-if="!hasCiEnabled"
|
||||
:href="helpPagePath"
|
||||
class="btn btn-info">
|
||||
Get started with Pipelines
|
||||
</a>
|
||||
|
||||
<a
|
||||
:href="ciLintPath"
|
||||
class="btn btn-default">
|
||||
CI Lint
|
||||
</a>
|
||||
</div>
|
||||
`,
|
||||
};
|
|
@ -0,0 +1,68 @@
|
|||
export default {
|
||||
props: {
|
||||
scope: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
count: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
paths: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
template: `
|
||||
<ul class="nav-links">
|
||||
<li
|
||||
class="js-pipelines-tab-all"
|
||||
:class="{ 'active': scope === 'all'}">
|
||||
<a :href="paths.allPath">
|
||||
All
|
||||
<span class="badge js-totalbuilds-count">
|
||||
{{count.all}}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="js-pipelines-tab-pending"
|
||||
:class="{ 'active': scope === 'pending'}">
|
||||
<a :href="paths.pendingPath">
|
||||
Pending
|
||||
<span class="badge">
|
||||
{{count.pending}}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="js-pipelines-tab-running"
|
||||
:class="{ 'active': scope === 'running'}">
|
||||
<a :href="paths.runningPath">
|
||||
Running
|
||||
<span class="badge">
|
||||
{{count.running}}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="js-pipelines-tab-finished"
|
||||
:class="{ 'active': scope === 'finished'}">
|
||||
<a :href="paths.finishedPath">
|
||||
Finished
|
||||
<span class="badge">
|
||||
{{count.finished}}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="js-pipelines-tab-branches"
|
||||
:class="{ 'active': scope === 'branches'}">
|
||||
<a :href="paths.branchesPath">Branches</a>
|
||||
</li>
|
||||
<li class="js-pipelines-tab-tags"
|
||||
:class="{ 'active': scope === 'tags'}">
|
||||
<a :href="paths.tagsPath">Tags</a>
|
||||
</li>
|
||||
</ul>
|
||||
`,
|
||||
};
|
|
@ -4,23 +4,19 @@ import PipelinesComponent from './pipelines';
|
|||
import '../vue_shared/vue_resource_interceptor';
|
||||
|
||||
$(() => new Vue({
|
||||
el: document.querySelector('.vue-pipelines-index'),
|
||||
el: document.querySelector('#pipelines-list-vue'),
|
||||
|
||||
data() {
|
||||
const project = document.querySelector('.pipelines');
|
||||
const store = new PipelinesStore();
|
||||
|
||||
return {
|
||||
store,
|
||||
endpoint: project.dataset.url,
|
||||
};
|
||||
},
|
||||
components: {
|
||||
'vue-pipelines': PipelinesComponent,
|
||||
},
|
||||
template: `
|
||||
<vue-pipelines
|
||||
:endpoint="endpoint"
|
||||
:store="store" />
|
||||
<vue-pipelines :store="store" />
|
||||
`,
|
||||
}));
|
||||
|
|
|
@ -1,19 +1,15 @@
|
|||
/* global Flash */
|
||||
/* eslint-disable no-new */
|
||||
import '~/flash';
|
||||
import Vue from 'vue';
|
||||
import PipelinesService from './services/pipelines_service';
|
||||
import eventHub from './event_hub';
|
||||
import PipelinesTableComponent from '../vue_shared/components/pipelines_table';
|
||||
import TablePaginationComponent from '../vue_shared/components/table_pagination';
|
||||
import EmptyState from './components/empty_state';
|
||||
import ErrorState from './components/error_state';
|
||||
import NavigationTabs from './components/navigation_tabs';
|
||||
import NavigationControls from './components/nav_controls';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
endpoint: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
store: {
|
||||
type: Object,
|
||||
required: true,
|
||||
|
@ -23,17 +19,109 @@ export default {
|
|||
components: {
|
||||
'gl-pagination': TablePaginationComponent,
|
||||
'pipelines-table-component': PipelinesTableComponent,
|
||||
'empty-state': EmptyState,
|
||||
'error-state': ErrorState,
|
||||
'navigation-tabs': NavigationTabs,
|
||||
'navigation-controls': NavigationControls,
|
||||
},
|
||||
|
||||
data() {
|
||||
const pipelinesData = document.querySelector('#pipelines-list-vue').dataset;
|
||||
|
||||
return {
|
||||
endpoint: pipelinesData.endpoint,
|
||||
cssClass: pipelinesData.cssClass,
|
||||
helpPagePath: pipelinesData.helpPagePath,
|
||||
newPipelinePath: pipelinesData.newPipelinePath,
|
||||
canCreatePipeline: pipelinesData.canCreatePipeline,
|
||||
allPath: pipelinesData.allPath,
|
||||
pendingPath: pipelinesData.pendingPath,
|
||||
runningPath: pipelinesData.runningPath,
|
||||
finishedPath: pipelinesData.finishedPath,
|
||||
branchesPath: pipelinesData.branchesPath,
|
||||
tagsPath: pipelinesData.tagsPath,
|
||||
hasCi: pipelinesData.hasCi,
|
||||
ciLintPath: pipelinesData.ciLintPath,
|
||||
state: this.store.state,
|
||||
apiScope: 'all',
|
||||
pagenum: 1,
|
||||
pageRequest: false,
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
canCreatePipelineParsed() {
|
||||
return gl.utils.convertPermissionToBoolean(this.canCreatePipeline);
|
||||
},
|
||||
|
||||
scope() {
|
||||
const scope = gl.utils.getParameterByName('scope');
|
||||
return scope === null ? 'all' : scope;
|
||||
},
|
||||
|
||||
shouldRenderErrorState() {
|
||||
return this.hasError && !this.isLoading;
|
||||
},
|
||||
|
||||
/**
|
||||
* The empty state should only be rendered when the request is made to fetch all pipelines
|
||||
* and none is returned.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
shouldRenderEmptyState() {
|
||||
return !this.isLoading &&
|
||||
!this.hasError &&
|
||||
!this.state.pipelines.length &&
|
||||
(this.scope === 'all' || this.scope === null);
|
||||
},
|
||||
|
||||
/**
|
||||
* When a specific scope does not have pipelines we render a message.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
shouldRenderNoPipelinesMessage() {
|
||||
return !this.isLoading &&
|
||||
!this.hasError &&
|
||||
!this.state.pipelines.length &&
|
||||
this.scope !== 'all' &&
|
||||
this.scope !== null;
|
||||
},
|
||||
|
||||
shouldRenderTable() {
|
||||
return !this.hasError &&
|
||||
!this.isLoading && this.state.pipelines.length;
|
||||
},
|
||||
|
||||
/**
|
||||
* Pagination should only be rendered when there is more than one page.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
shouldRenderPagination() {
|
||||
return !this.isLoading &&
|
||||
this.state.pipelines.length &&
|
||||
this.state.pageInfo.total > this.state.pageInfo.perPage;
|
||||
},
|
||||
|
||||
hasCiEnabled() {
|
||||
return this.hasCi !== undefined;
|
||||
},
|
||||
|
||||
paths() {
|
||||
return {
|
||||
allPath: this.allPath,
|
||||
pendingPath: this.pendingPath,
|
||||
finishedPath: this.finishedPath,
|
||||
runningPath: this.runningPath,
|
||||
branchesPath: this.branchesPath,
|
||||
tagsPath: this.tagsPath,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.service = new PipelinesService(this.endpoint);
|
||||
|
||||
|
@ -69,7 +157,7 @@ export default {
|
|||
const pageNumber = gl.utils.getParameterByName('page') || this.pagenum;
|
||||
const scope = gl.utils.getParameterByName('scope') || this.apiScope;
|
||||
|
||||
this.pageRequest = true;
|
||||
this.isLoading = true;
|
||||
return this.service.getPipelines(scope, pageNumber)
|
||||
.then(resp => ({
|
||||
headers: resp.headers,
|
||||
|
@ -81,41 +169,72 @@ export default {
|
|||
this.store.storePagination(response.headers);
|
||||
})
|
||||
.then(() => {
|
||||
this.pageRequest = false;
|
||||
this.isLoading = false;
|
||||
})
|
||||
.catch(() => {
|
||||
this.pageRequest = false;
|
||||
new Flash('An error occurred while fetching the pipelines, please reload the page again.');
|
||||
this.hasError = true;
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
template: `
|
||||
<div>
|
||||
<div class="pipelines realtime-loading" v-if="pageRequest">
|
||||
<i class="fa fa-spinner fa-spin" aria-hidden="true"></i>
|
||||
<div :class="cssClass">
|
||||
|
||||
<div
|
||||
class="top-area"
|
||||
v-if="!isLoading && !shouldRenderEmptyState">
|
||||
<navigation-tabs
|
||||
:scope="scope"
|
||||
:count="state.count"
|
||||
:paths="paths" />
|
||||
|
||||
<navigation-controls
|
||||
:new-pipeline-path="newPipelinePath"
|
||||
:has-ci-enabled="hasCiEnabled"
|
||||
:help-page-path="helpPagePath"
|
||||
:ciLintPath="ciLintPath"
|
||||
:can-create-pipeline="canCreatePipelineParsed " />
|
||||
</div>
|
||||
|
||||
<div class="blank-state blank-state-no-icon"
|
||||
v-if="!pageRequest && state.pipelines.length === 0">
|
||||
<h2 class="blank-state-title js-blank-state-title">
|
||||
No pipelines to show
|
||||
</h2>
|
||||
<div class="content-list pipelines">
|
||||
|
||||
<div
|
||||
class="realtime-loading"
|
||||
v-if="isLoading">
|
||||
<i
|
||||
class="fa fa-spinner fa-spin"
|
||||
aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div class="table-holder" v-if="!pageRequest && state.pipelines.length">
|
||||
<empty-state
|
||||
v-if="shouldRenderEmptyState"
|
||||
:help-page-path="helpPagePath" />
|
||||
|
||||
<error-state v-if="shouldRenderErrorState" />
|
||||
|
||||
<div
|
||||
class="blank-state blank-state-no-icon"
|
||||
v-if="shouldRenderNoPipelinesMessage">
|
||||
<h2 class="blank-state-title js-blank-state-title">No pipelines to show.</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="table-holder"
|
||||
v-if="shouldRenderTable">
|
||||
|
||||
<pipelines-table-component
|
||||
:pipelines="state.pipelines"
|
||||
:service="service"/>
|
||||
</div>
|
||||
|
||||
<gl-pagination
|
||||
v-if="!pageRequest && state.pipelines.length && state.pageInfo.total > state.pageInfo.perPage"
|
||||
v-if="shouldRenderPagination"
|
||||
:pagenum="pagenum"
|
||||
:change="change"
|
||||
:count="state.count.all"
|
||||
:pageInfo="state.pageInfo"
|
||||
>
|
||||
</gl-pagination>
|
||||
:pageInfo="state.pageInfo"/>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
.realtime-loading {
|
||||
font-size: 40px;
|
||||
text-align: center;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.stage {
|
||||
|
@ -13,6 +14,10 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
margin: 5% auto 0;
|
||||
}
|
||||
|
||||
.table-holder {
|
||||
width: 100%;
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
- disable_initialization = local_assigns.fetch(:disable_initialization, false)
|
||||
#commit-pipeline-table-view{ data: { disable_initialization: disable_initialization,
|
||||
endpoint: endpoint,
|
||||
"help-page-path" => help_page_path('ci/quick_start/README'),
|
||||
} }
|
||||
|
||||
- content_for :page_specific_javascripts do
|
||||
|
|
|
@ -2,53 +2,19 @@
|
|||
- page_title "Pipelines"
|
||||
= render "projects/pipelines/head"
|
||||
|
||||
%div{ class: container_class }
|
||||
.top-area
|
||||
%ul.nav-links
|
||||
%li.js-pipelines-tab-all{ class: active_when(@scope.nil?) }>
|
||||
= link_to project_pipelines_path(@project) do
|
||||
All
|
||||
%span.badge.js-totalbuilds-count
|
||||
= number_with_delimiter(@pipelines_count)
|
||||
|
||||
%li.js-pipelines-tab-pending{ class: active_when(@scope == 'pending') }>
|
||||
= link_to project_pipelines_path(@project, scope: :pending) do
|
||||
Pending
|
||||
%span.badge
|
||||
= number_with_delimiter(@pending_count)
|
||||
|
||||
%li.js-pipelines-tab-running{ class: active_when(@scope == 'running') }>
|
||||
= link_to project_pipelines_path(@project, scope: :running) do
|
||||
Running
|
||||
%span.badge.js-running-count
|
||||
= number_with_delimiter(@running_count)
|
||||
|
||||
%li.js-pipelines-tab-finished{ class: active_when(@scope == 'finished') }>
|
||||
= link_to project_pipelines_path(@project, scope: :finished) do
|
||||
Finished
|
||||
%span.badge
|
||||
= number_with_delimiter(@finished_count)
|
||||
|
||||
%li.js-pipelines-tab-branches{ class: active_when(@scope == 'branches') }>
|
||||
= link_to project_pipelines_path(@project, scope: :branches) do
|
||||
Branches
|
||||
|
||||
%li.js-pipelines-tab-tags{ class: active_when(@scope == 'tags') }>
|
||||
= link_to project_pipelines_path(@project, scope: :tags) do
|
||||
Tags
|
||||
|
||||
.nav-controls
|
||||
- if can? current_user, :create_pipeline, @project
|
||||
= link_to new_namespace_project_pipeline_path(@project.namespace, @project), class: 'btn btn-create' do
|
||||
Run pipeline
|
||||
|
||||
- unless @repository.gitlab_ci_yml
|
||||
= link_to 'Get started with Pipelines', help_page_path('ci/quick_start/README'), class: 'btn btn-info'
|
||||
|
||||
= link_to ci_lint_path, class: 'btn btn-default' do
|
||||
%span CI Lint
|
||||
.content-list.pipelines{ data: { url: namespace_project_pipelines_path(@project.namespace, @project, format: :json) } }
|
||||
.vue-pipelines-index
|
||||
#pipelines-list-vue{ data: { endpoint: namespace_project_pipelines_path(@project.namespace, @project, format: :json),
|
||||
"css-class" => container_class,
|
||||
"help-page-path" => help_page_path('ci/quick_start/README'),
|
||||
"new-pipeline-path" => new_namespace_project_pipeline_path(@project.namespace, @project),
|
||||
"can-create-pipeline" => can?(current_user, :create_pipeline, @project).to_s,
|
||||
"all-path" => project_pipelines_path(@project),
|
||||
"pending-path" => project_pipelines_path(@project, scope: :pending),
|
||||
"running-path" => project_pipelines_path(@project, scope: :running),
|
||||
"finished-path" => project_pipelines_path(@project, scope: :finished),
|
||||
"branches-path" => project_pipelines_path(@project, scope: :branches),
|
||||
"tags-path" => project_pipelines_path(@project, scope: :tags),
|
||||
"has-ci" => @repository.gitlab_ci_yml,
|
||||
"ci-lint-path" => ci_lint_path } }
|
||||
|
||||
= page_specific_javascript_bundle_tag('common_vue')
|
||||
= page_specific_javascript_bundle_tag('vue_pipelines')
|
||||
|
|
1
app/views/shared/empty_states/icons/_pipelines_empty.svg
Normal file
1
app/views/shared/empty_states/icons/_pipelines_empty.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 150"><g fill="none" fill-rule="evenodd" transform="translate(0-3)"><g transform="translate(0 105)"><g fill="#e5e5e5"><rect width="78" height="4" x="34" y="21" opacity=".5" rx="2"/><path d="m152 23c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2"/></g><g transform="translate(0 4)"><path fill="#98d7b2" fill-rule="nonzero" d="m19 38c-10.493 0-19-8.507-19-19 0-10.493 8.507-19 19-19 10.493 0 19 8.507 19 19 0 10.493-8.507 19-19 19m0-4c8.284 0 15-6.716 15-15 0-8.284-6.716-15-15-15-8.284 0-15 6.716-15 15 0 8.284 6.716 15 15 15"/><path fill="#31af64" d="m17.07 21.02l-2.829-2.829c-.786-.786-2.047-.781-2.828 0-.786.786-.781 2.047 0 2.828l4.243 4.243c.392.392.902.587 1.412.588.512.002 1.021-.193 1.41-.582l7.79-7.79c.777-.777.775-2.042-.006-2.823-.786-.786-2.045-.784-2.823-.006l-6.37 6.37"/></g><g fill="#e52c5a" transform="translate(102)"><path fill-rule="nonzero" d="m24 47.5c-12.979 0-23.5-10.521-23.5-23.5 0-12.979 10.521-23.5 23.5-23.5 12.979 0 23.5 10.521 23.5 23.5 0 12.979-10.521 23.5-23.5 23.5m0-5c10.217 0 18.5-8.283 18.5-18.5 0-10.217-8.283-18.5-18.5-18.5-10.217 0-18.5 8.283-18.5 18.5 0 10.217 8.283 18.5 18.5 18.5"/><path d="m28.24 24l2.833-2.833c1.167-1.167 1.167-3.067-.004-4.239-1.169-1.169-3.069-1.173-4.239-.004l-2.833 2.833-2.833-2.833c-1.167-1.167-3.067-1.167-4.239.004-1.169 1.169-1.173 3.069-.004 4.239l2.833 2.833-2.833 2.833c-1.167 1.167-1.167 3.067.004 4.239 1.169 1.169 3.069 1.173 4.239.004l2.833-2.833 2.833 2.833c1.167 1.167 3.067 1.167 4.239-.004 1.169-1.169 1.173-3.069.004-4.239l-2.833-2.833"/></g><path fill="#e5e5e5" fill-rule="nonzero" d="m236 37c-7.732 0-14-6.268-14-14 0-7.732 6.268-14 14-14 7.732 0 14 6.268 14 14 0 7.732-6.268 14-14 14m0-4c5.523 0 10-4.477 10-10 0-5.523-4.477-10-10-10-5.523 0-10 4.477-10 10 0 5.523 4.477 10 10 10"/></g><g transform="translate(69 3)"><path fill="#e5e5e5" fill-rule="nonzero" d="m4 11.99v60.02c0 4.413 3.583 7.99 8 7.99h89.991c4.419 0 8-3.579 8-7.99v-60.02c0-4.413-3.583-7.99-8-7.99h-89.991c-4.419 0-8 3.579-8 7.99m-4 0c0-6.622 5.378-11.99 12-11.99h89.991c6.629 0 12 5.367 12 11.99v60.02c0 6.622-5.378 11.99-12 11.99h-89.991c-6.629 0-12-5.367-12-11.99v-60.02m52.874 80.3l-13.253-15.292h34.76l-13.253 15.292c-2.237 2.582-6.01 2.585-8.253 0m3.02-2.62c.644.743 1.564.743 2.207 0l7.516-8.673h-17.24l7.516 8.673"/><rect width="18" height="6" x="15" y="23" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="39" y="39" fill="#e52c5a" rx="3"/><rect width="18" height="6" x="33" y="55" fill="#e5e5e5" rx="3"/><rect width="12" height="6" x="39" y="23" fill="#fde5d8" rx="3"/><rect width="12" height="6" x="57" y="55" fill="#e52c5a" rx="3"/><rect width="12" height="6" x="15" y="55" fill="#b5a7dd" rx="3"/><rect width="18" height="6" x="81" y="23" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="15" y="39" fill="#fde5d8" rx="3"/><rect width="6" height="6" x="57" y="23" fill="#e52c5a" rx="3"/><g fill="#fde5d8"><rect width="6" height="6" x="69" y="23" rx="3"/><rect width="6" height="6" x="75" y="39" rx="3"/></g><rect width="6" height="6" x="63" y="39" fill="#e52c5a" rx="3"/></g><g transform="matrix(.70711-.70711.70711.70711 84.34 52.5)"><path fill="#6b4fbb" fill-rule="nonzero" d="m28.02 67.48c-15.927-2.825-28.02-16.738-28.02-33.476 0-18.778 15.222-34 34-34 18.778 0 34 15.222 34 34 0 16.738-12.1 30.652-28.02 33.476.015.173.023.347.023.524v21.999c0 3.314-2.693 6-6 6-3.314 0-6-2.682-6-6v-21.999c0-.177.008-.351.023-.524m5.977-7.476c14.359 0 26-11.641 26-26 0-14.359-11.641-26-26-26-14.359 0-26 11.641-26 26 0 14.359 11.641 26 26 26"/><path fill="#fff" fill-opacity=".3" stroke="#6b4fbb" stroke-width="8" d="m31 71c16.569 0 30-13.431 30-30 0-16.569-13.431-30-30-30" transform="matrix(.86603.5-.5.86603 26.663-17.507)"/></g></g></svg>
|
After Width: | Height: | Size: 4.2 KiB |
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 7.1 KiB |
4
changelogs/unreleased/27574-pipelines-empty-state.yml
Normal file
4
changelogs/unreleased/27574-pipelines-empty-state.yml
Normal file
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
title: Adds empty and error state to pipelines
|
||||
merge_request:
|
||||
author:
|
|
@ -442,7 +442,7 @@ describe 'Pipelines', :feature, :js do
|
|||
context 'when project is public' do
|
||||
let(:project) { create(:project, :public) }
|
||||
|
||||
it { expect(page).to have_content 'No pipelines to show' }
|
||||
it { expect(page).to have_content 'Build with confidence' }
|
||||
it { expect(page).to have_http_status(:success) }
|
||||
end
|
||||
|
||||
|
|
|
@ -33,7 +33,8 @@ describe('Pipelines table in Commits and Merge requests', () => {
|
|||
});
|
||||
|
||||
setTimeout(() => {
|
||||
expect(component.$el.querySelector('.js-blank-state-title').textContent).toContain('No pipelines to show');
|
||||
expect(component.$el.querySelector('.empty-state')).toBeDefined();
|
||||
expect(component.$el.querySelector('.realtime-loading')).toBe(null);
|
||||
done();
|
||||
}, 1);
|
||||
});
|
||||
|
@ -63,6 +64,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
|
|||
|
||||
setTimeout(() => {
|
||||
expect(component.$el.querySelectorAll('table > tbody > tr').length).toEqual(1);
|
||||
expect(component.$el.querySelector('.realtime-loading')).toBe(null);
|
||||
done();
|
||||
}, 0);
|
||||
});
|
||||
|
@ -92,7 +94,8 @@ describe('Pipelines table in Commits and Merge requests', () => {
|
|||
});
|
||||
|
||||
setTimeout(() => {
|
||||
expect(component.$el.querySelector('.js-blank-state-title').textContent).toContain('No pipelines to show');
|
||||
expect(component.$el.querySelector('.js-pipelines-error-state')).toBeDefined();
|
||||
expect(component.$el.querySelector('.realtime-loading')).toBe(null);
|
||||
done();
|
||||
}, 0);
|
||||
});
|
||||
|
|
14
spec/javascripts/fixtures/pipelines.html.haml
Normal file
14
spec/javascripts/fixtures/pipelines.html.haml
Normal file
|
@ -0,0 +1,14 @@
|
|||
%div
|
||||
#pipelines-list-vue{ data: { endpoint: 'foo',
|
||||
"css-class" => 'foo',
|
||||
"help-page-path" => 'foo',
|
||||
"new-pipeline-path" => 'foo',
|
||||
"can-create-pipeline" => 'true',
|
||||
"all-path" => 'foo',
|
||||
"pending-path" => 'foo',
|
||||
"running-path" => 'foo',
|
||||
"finished-path" => 'foo',
|
||||
"branches-path" => 'foo',
|
||||
"tags-path" => 'foo',
|
||||
"has-ci" => 'foo',
|
||||
"ci-lint-path" => 'foo' } }
|
|
@ -1,2 +1 @@
|
|||
#commit-pipeline-table-view{ data: { endpoint: "endpoint" } }
|
||||
.pipeline-svgs{ data: { "commit_icon_svg": "svg"} }
|
||||
#commit-pipeline-table-view{ data: { endpoint: "endpoint", "help-page-path": "foo" } }
|
||||
|
|
38
spec/javascripts/vue_pipelines_index/empty_state_spec.js
Normal file
38
spec/javascripts/vue_pipelines_index/empty_state_spec.js
Normal file
|
@ -0,0 +1,38 @@
|
|||
import Vue from 'vue';
|
||||
import emptyStateComp from '~/vue_pipelines_index/components/empty_state';
|
||||
|
||||
describe('Pipelines Empty State', () => {
|
||||
let component;
|
||||
let EmptyStateComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
EmptyStateComponent = Vue.extend(emptyStateComp);
|
||||
|
||||
component = new EmptyStateComponent({
|
||||
propsData: {
|
||||
helpPagePath: 'foo',
|
||||
},
|
||||
}).$mount();
|
||||
});
|
||||
|
||||
it('should render empty state SVG', () => {
|
||||
expect(component.$el.querySelector('.svg-content svg')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should render emtpy state information', () => {
|
||||
expect(component.$el.querySelector('h4').textContent).toContain('Build with confidence');
|
||||
|
||||
expect(
|
||||
component.$el.querySelector('p').textContent,
|
||||
).toContain('Continous Integration can help catch bugs by running your tests automatically');
|
||||
|
||||
expect(
|
||||
component.$el.querySelector('p').textContent,
|
||||
).toContain('Continuous Deployment can help you deliver code to your product environment');
|
||||
});
|
||||
|
||||
it('should render a link with provided help path', () => {
|
||||
expect(component.$el.querySelector('.btn-info').getAttribute('href')).toEqual('foo');
|
||||
expect(component.$el.querySelector('.btn-info').textContent).toContain('Get started with Pipelines');
|
||||
});
|
||||
});
|
23
spec/javascripts/vue_pipelines_index/error_state_spec.js
Normal file
23
spec/javascripts/vue_pipelines_index/error_state_spec.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
import Vue from 'vue';
|
||||
import errorStateComp from '~/vue_pipelines_index/components/error_state';
|
||||
|
||||
describe('Pipelines Error State', () => {
|
||||
let component;
|
||||
let ErrorStateComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
ErrorStateComponent = Vue.extend(errorStateComp);
|
||||
|
||||
component = new ErrorStateComponent().$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');
|
||||
});
|
||||
});
|
107
spec/javascripts/vue_pipelines_index/mock_data.js
Normal file
107
spec/javascripts/vue_pipelines_index/mock_data.js
Normal file
|
@ -0,0 +1,107 @@
|
|||
export default {
|
||||
pipelines: [{
|
||||
id: 115,
|
||||
user: {
|
||||
name: 'Root',
|
||||
username: 'root',
|
||||
id: 1,
|
||||
state: 'active',
|
||||
avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
|
||||
web_url: 'http://localhost:3000/root',
|
||||
},
|
||||
path: '/root/review-app/pipelines/115',
|
||||
details: {
|
||||
status: {
|
||||
icon: 'icon_status_failed',
|
||||
text: 'failed',
|
||||
label: 'failed',
|
||||
group: 'failed',
|
||||
has_details: true,
|
||||
details_path: '/root/review-app/pipelines/115',
|
||||
},
|
||||
duration: null,
|
||||
finished_at: '2017-03-17T19:00:15.996Z',
|
||||
stages: [{
|
||||
name: 'build',
|
||||
title: 'build: failed',
|
||||
status: {
|
||||
icon: 'icon_status_failed',
|
||||
text: 'failed',
|
||||
label: 'failed',
|
||||
group: 'failed',
|
||||
has_details: true,
|
||||
details_path: '/root/review-app/pipelines/115#build',
|
||||
},
|
||||
path: '/root/review-app/pipelines/115#build',
|
||||
dropdown_path: '/root/review-app/pipelines/115/stage.json?stage=build',
|
||||
},
|
||||
{
|
||||
name: 'review',
|
||||
title: 'review: skipped',
|
||||
status: {
|
||||
icon: 'icon_status_skipped',
|
||||
text: 'skipped',
|
||||
label: 'skipped',
|
||||
group: 'skipped',
|
||||
has_details: true,
|
||||
details_path: '/root/review-app/pipelines/115#review',
|
||||
},
|
||||
path: '/root/review-app/pipelines/115#review',
|
||||
dropdown_path: '/root/review-app/pipelines/115/stage.json?stage=review',
|
||||
}],
|
||||
artifacts: [],
|
||||
manual_actions: [{
|
||||
name: 'stop_review',
|
||||
path: '/root/review-app/builds/3766/play',
|
||||
}],
|
||||
},
|
||||
flags: {
|
||||
latest: true,
|
||||
triggered: false,
|
||||
stuck: false,
|
||||
yaml_errors: false,
|
||||
retryable: true,
|
||||
cancelable: false,
|
||||
},
|
||||
ref: {
|
||||
name: 'thisisabranch',
|
||||
path: '/root/review-app/tree/thisisabranch',
|
||||
tag: false,
|
||||
branch: true,
|
||||
},
|
||||
commit: {
|
||||
id: '9e87f87625b26c42c59a2ee0398f81d20cdfe600',
|
||||
short_id: '9e87f876',
|
||||
title: 'Update README.md',
|
||||
created_at: '2017-03-15T22:58:28.000+00:00',
|
||||
parent_ids: ['3744f9226e699faec2662a8b267e5d3fd0bfff0e'],
|
||||
message: 'Update README.md',
|
||||
author_name: 'Root',
|
||||
author_email: 'admin@example.com',
|
||||
authored_date: '2017-03-15T22:58:28.000+00:00',
|
||||
committer_name: 'Root',
|
||||
committer_email: 'admin@example.com',
|
||||
committed_date: '2017-03-15T22:58:28.000+00:00',
|
||||
author: {
|
||||
name: 'Root',
|
||||
username: 'root',
|
||||
id: 1,
|
||||
state: 'active',
|
||||
avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
|
||||
web_url: 'http://localhost:3000/root',
|
||||
},
|
||||
author_gravatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
|
||||
commit_url: 'http://localhost:3000/root/review-app/commit/9e87f87625b26c42c59a2ee0398f81d20cdfe600',
|
||||
commit_path: '/root/review-app/commit/9e87f87625b26c42c59a2ee0398f81d20cdfe600',
|
||||
},
|
||||
retry_path: '/root/review-app/pipelines/115/retry',
|
||||
created_at: '2017-03-15T22:58:33.436Z',
|
||||
updated_at: '2017-03-17T19:00:15.997Z',
|
||||
}],
|
||||
count: {
|
||||
all: 52,
|
||||
running: 0,
|
||||
pending: 0,
|
||||
finished: 52,
|
||||
},
|
||||
};
|
93
spec/javascripts/vue_pipelines_index/nav_controls_spec.js
Normal file
93
spec/javascripts/vue_pipelines_index/nav_controls_spec.js
Normal file
|
@ -0,0 +1,93 @@
|
|||
import Vue from 'vue';
|
||||
import navControlsComp from '~/vue_pipelines_index/components/nav_controls';
|
||||
|
||||
describe('Pipelines Nav Controls', () => {
|
||||
let NavControlsComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
NavControlsComponent = Vue.extend(navControlsComp);
|
||||
});
|
||||
|
||||
it('should render link to create a new pipeline', () => {
|
||||
const mockData = {
|
||||
newPipelinePath: 'foo',
|
||||
hasCiEnabled: true,
|
||||
helpPagePath: 'foo',
|
||||
ciLintPath: 'foo',
|
||||
canCreatePipeline: true,
|
||||
};
|
||||
|
||||
const component = new NavControlsComponent({
|
||||
propsData: mockData,
|
||||
}).$mount();
|
||||
|
||||
expect(component.$el.querySelector('.btn-create').textContent).toContain('Run Pipeline');
|
||||
expect(component.$el.querySelector('.btn-create').getAttribute('href')).toEqual(mockData.newPipelinePath);
|
||||
});
|
||||
|
||||
it('should not render link to create pipeline if no permission is provided', () => {
|
||||
const mockData = {
|
||||
newPipelinePath: 'foo',
|
||||
hasCiEnabled: true,
|
||||
helpPagePath: 'foo',
|
||||
ciLintPath: 'foo',
|
||||
canCreatePipeline: false,
|
||||
};
|
||||
|
||||
const component = new NavControlsComponent({
|
||||
propsData: mockData,
|
||||
}).$mount();
|
||||
|
||||
expect(component.$el.querySelector('.btn-create')).toEqual(null);
|
||||
});
|
||||
|
||||
it('should render link for CI lint', () => {
|
||||
const mockData = {
|
||||
newPipelinePath: 'foo',
|
||||
hasCiEnabled: true,
|
||||
helpPagePath: 'foo',
|
||||
ciLintPath: 'foo',
|
||||
canCreatePipeline: true,
|
||||
};
|
||||
|
||||
const component = new NavControlsComponent({
|
||||
propsData: mockData,
|
||||
}).$mount();
|
||||
|
||||
expect(component.$el.querySelector('.btn-default').textContent).toContain('CI Lint');
|
||||
expect(component.$el.querySelector('.btn-default').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',
|
||||
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',
|
||||
ciLintPath: 'foo',
|
||||
canCreatePipeline: true,
|
||||
};
|
||||
|
||||
const component = new NavControlsComponent({
|
||||
propsData: mockData,
|
||||
}).$mount();
|
||||
|
||||
expect(component.$el.querySelector('.btn-info')).toEqual(null);
|
||||
});
|
||||
});
|
114
spec/javascripts/vue_pipelines_index/pipelines_spec.js
Normal file
114
spec/javascripts/vue_pipelines_index/pipelines_spec.js
Normal file
|
@ -0,0 +1,114 @@
|
|||
import Vue from 'vue';
|
||||
import pipelinesComp from '~/vue_pipelines_index/pipelines';
|
||||
import Store from '~/vue_pipelines_index/stores/pipelines_store';
|
||||
import pipelinesData from './mock_data';
|
||||
|
||||
describe('Pipelines', () => {
|
||||
preloadFixtures('static/pipelines.html.raw');
|
||||
|
||||
let PipelinesComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
loadFixtures('static/pipelines.html.raw');
|
||||
|
||||
PipelinesComponent = Vue.extend(pipelinesComp);
|
||||
});
|
||||
|
||||
describe('successfull request', () => {
|
||||
describe('with pipelines', () => {
|
||||
const pipelinesInterceptor = (request, next) => {
|
||||
next(request.respondWith(JSON.stringify(pipelinesData), {
|
||||
status: 200,
|
||||
}));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Vue.http.interceptors.push(pipelinesInterceptor);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Vue.http.interceptors = _.without(
|
||||
Vue.http.interceptors, pipelinesInterceptor,
|
||||
);
|
||||
});
|
||||
|
||||
it('should render table', (done) => {
|
||||
const component = new PipelinesComponent({
|
||||
propsData: {
|
||||
store: new Store(),
|
||||
},
|
||||
}).$mount();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(component.$el.querySelector('.table-holder')).toBeDefined();
|
||||
expect(component.$el.querySelector('.realtime-loading')).toBe(null);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('without pipelines', () => {
|
||||
const emptyInterceptor = (request, next) => {
|
||||
next(request.respondWith(JSON.stringify([]), {
|
||||
status: 200,
|
||||
}));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Vue.http.interceptors.push(emptyInterceptor);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Vue.http.interceptors = _.without(
|
||||
Vue.http.interceptors, emptyInterceptor,
|
||||
);
|
||||
});
|
||||
|
||||
it('should render empty state', (done) => {
|
||||
const component = new PipelinesComponent({
|
||||
propsData: {
|
||||
store: new Store(),
|
||||
},
|
||||
}).$mount();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(component.$el.querySelector('.empty-state')).toBeDefined();
|
||||
expect(component.$el.querySelector('.realtime-loading')).toBe(null);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsuccessfull request', () => {
|
||||
const errorInterceptor = (request, next) => {
|
||||
next(request.respondWith(JSON.stringify([]), {
|
||||
status: 500,
|
||||
}));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Vue.http.interceptors.push(errorInterceptor);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Vue.http.interceptors = _.without(
|
||||
Vue.http.interceptors, errorInterceptor,
|
||||
);
|
||||
});
|
||||
|
||||
it('should render error state', (done) => {
|
||||
const component = new PipelinesComponent({
|
||||
propsData: {
|
||||
store: new Store(),
|
||||
},
|
||||
}).$mount();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(component.$el.querySelector('.js-pipelines-error-state')).toBeDefined();
|
||||
expect(component.$el.querySelector('.realtime-loading')).toBe(null);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue