-
+
+
+
+
+
+
-
-
- No pipelines to show
-
-
+
-
+
+
+
-
-
+
+
+
+
+
+
No pipelines to show.
+
+
+
+
+
+
`,
};
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index 772f05feb12..a20db153d09 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -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%;
diff --git a/app/views/projects/commit/_pipelines_list.haml b/app/views/projects/commit/_pipelines_list.haml
index da5a676274f..09e3a775d1c 100644
--- a/app/views/projects/commit/_pipelines_list.haml
+++ b/app/views/projects/commit/_pipelines_list.haml
@@ -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
diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml
index 5d59ce06612..3d73284699f 100644
--- a/app/views/projects/pipelines/index.html.haml
+++ b/app/views/projects/pipelines/index.html.haml
@@ -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')
diff --git a/app/views/shared/empty_states/icons/_pipelines_empty.svg b/app/views/shared/empty_states/icons/_pipelines_empty.svg
new file mode 100644
index 00000000000..8119d5bebe0
--- /dev/null
+++ b/app/views/shared/empty_states/icons/_pipelines_empty.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/views/shared/empty_states/icons/_pipelines_failed.svg b/app/views/shared/empty_states/icons/_pipelines_failed.svg
new file mode 100644
index 00000000000..7dbabf7e4ef
--- /dev/null
+++ b/app/views/shared/empty_states/icons/_pipelines_failed.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/changelogs/unreleased/27574-pipelines-empty-state.yml b/changelogs/unreleased/27574-pipelines-empty-state.yml
new file mode 100644
index 00000000000..c26ea97205f
--- /dev/null
+++ b/changelogs/unreleased/27574-pipelines-empty-state.yml
@@ -0,0 +1,4 @@
+---
+title: Adds empty and error state to pipelines
+merge_request:
+author:
diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb
index 162056671e0..2272b19bc8f 100644
--- a/spec/features/projects/pipelines/pipelines_spec.rb
+++ b/spec/features/projects/pipelines/pipelines_spec.rb
@@ -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
diff --git a/spec/javascripts/commit/pipelines/pipelines_spec.js b/spec/javascripts/commit/pipelines/pipelines_spec.js
index 75efcc06585..bc2e092db65 100644
--- a/spec/javascripts/commit/pipelines/pipelines_spec.js
+++ b/spec/javascripts/commit/pipelines/pipelines_spec.js
@@ -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);
});
diff --git a/spec/javascripts/fixtures/pipelines.html.haml b/spec/javascripts/fixtures/pipelines.html.haml
new file mode 100644
index 00000000000..418a38a0e2e
--- /dev/null
+++ b/spec/javascripts/fixtures/pipelines.html.haml
@@ -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' } }
diff --git a/spec/javascripts/fixtures/pipelines_table.html.haml b/spec/javascripts/fixtures/pipelines_table.html.haml
index fbe4a434f76..ad1682704bb 100644
--- a/spec/javascripts/fixtures/pipelines_table.html.haml
+++ b/spec/javascripts/fixtures/pipelines_table.html.haml
@@ -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" } }
diff --git a/spec/javascripts/vue_pipelines_index/empty_state_spec.js b/spec/javascripts/vue_pipelines_index/empty_state_spec.js
new file mode 100644
index 00000000000..733337168dc
--- /dev/null
+++ b/spec/javascripts/vue_pipelines_index/empty_state_spec.js
@@ -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');
+ });
+});
diff --git a/spec/javascripts/vue_pipelines_index/error_state_spec.js b/spec/javascripts/vue_pipelines_index/error_state_spec.js
new file mode 100644
index 00000000000..524e018b1fa
--- /dev/null
+++ b/spec/javascripts/vue_pipelines_index/error_state_spec.js
@@ -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');
+ });
+});
diff --git a/spec/javascripts/vue_pipelines_index/mock_data.js b/spec/javascripts/vue_pipelines_index/mock_data.js
new file mode 100644
index 00000000000..2365a662b9f
--- /dev/null
+++ b/spec/javascripts/vue_pipelines_index/mock_data.js
@@ -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,
+ },
+};
diff --git a/spec/javascripts/vue_pipelines_index/nav_controls_spec.js b/spec/javascripts/vue_pipelines_index/nav_controls_spec.js
new file mode 100644
index 00000000000..659c4854a56
--- /dev/null
+++ b/spec/javascripts/vue_pipelines_index/nav_controls_spec.js
@@ -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);
+ });
+});
diff --git a/spec/javascripts/vue_pipelines_index/pipelines_spec.js b/spec/javascripts/vue_pipelines_index/pipelines_spec.js
new file mode 100644
index 00000000000..725f6cb2d7a
--- /dev/null
+++ b/spec/javascripts/vue_pipelines_index/pipelines_spec.js
@@ -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();
+ });
+ });
+ });
+});