From 184f60a06f828ccbc9264d40e6daa48d60dca629 Mon Sep 17 00:00:00 2001
From: Filipa Lacerda <filipa@gitlab.com>
Date: Sun, 29 Jan 2017 15:30:04 +0000
Subject: [PATCH] Moves pagination to shared folder

Document and remove unused code

Declare components in a consistent way;
Use " instead of ' to improve consistency;
Update documentation;
Fix commit author verification to match the use cases;

Adds tests for the added components

Fix paths in pagination spec

Adds tests to pipelines table used in merge requests and commits

Use same resource interceptor

Fix eslint error
---
 .../commit/pipelines/pipelines_bundle.js.es6  | 100 +---------------
 .../commit/pipelines/pipelines_service.js.es6 |   5 +
 .../commit/pipelines/pipelines_table.js.es6   | 104 +++++++++++++++++
 .../environments/environments_bundle.js.es6   |   2 +-
 .../vue_resource_interceptor.js.es6           |  12 --
 .../vue_pipelines_index/pipelines.js.es6      |   4 +-
 .../vue_pipelines_index/time_ago.js.es6       |   2 +
 .../components/pipelines_table.js.es6         |  24 ++--
 .../components/pipelines_table_row.js.es6     | 102 ++++++++++-------
 .../components/table_pagination.js.es6}       |   0
 .../vue_resource_interceptor.js.es6           |  11 +-
 app/controllers/projects/commit_controller.rb |   1 -
 .../projects/merge_requests_controller.rb     |   1 -
 .../merge_requests/_new_submit.html.haml      |   6 +-
 .../projects/merge_requests/_show.html.haml   |   1 -
 .../commit/pipelines/mock_data.js.es6         |  90 +++++++++++++++
 .../commit/pipelines/pipelines_spec.js.es6    | 107 ++++++++++++++++++
 .../pipelines/pipelines_store_spec.js.es6     |  31 +++++
 .../fixtures/pipelines_table.html.haml        |   2 +
 .../pipelines_table_row_spec.js.es6           |  90 +++++++++++++++
 .../components/pipelines_table_spec.js.es6    |  67 +++++++++++
 .../components/table_pagination_spec.js.es6}  |   3 +-
 22 files changed, 591 insertions(+), 174 deletions(-)
 create mode 100644 app/assets/javascripts/commit/pipelines/pipelines_table.js.es6
 delete mode 100644 app/assets/javascripts/environments/vue_resource_interceptor.js.es6
 rename app/assets/javascripts/{vue_pagination/index.js.es6 => vue_shared/components/table_pagination.js.es6} (100%)
 create mode 100644 spec/javascripts/commit/pipelines/mock_data.js.es6
 create mode 100644 spec/javascripts/commit/pipelines/pipelines_spec.js.es6
 create mode 100644 spec/javascripts/commit/pipelines/pipelines_store_spec.js.es6
 create mode 100644 spec/javascripts/fixtures/pipelines_table.html.haml
 create mode 100644 spec/javascripts/vue_shared/components/pipelines_table_row_spec.js.es6
 create mode 100644 spec/javascripts/vue_shared/components/pipelines_table_spec.js.es6
 rename spec/javascripts/{vue_pagination/pagination_spec.js.es6 => vue_shared/components/table_pagination_spec.js.es6} (98%)

diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js.es6 b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js.es6
index a06aad17824..b21d13842a4 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js.es6
+++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js.es6
@@ -3,10 +3,6 @@
 
 //= require vue
 //= require_tree .
-//= require vue
-//= require vue-resource
-//= require vue_shared/vue_resource_interceptor
-//= require vue_shared/components/pipelines_table
 
 /**
  * Commits View > Pipelines Tab > Pipelines Table.
@@ -14,13 +10,6 @@
  *
  * Renders Pipelines table in pipelines tab in the commits show view.
  * Renders Pipelines table in pipelines tab in the merge request show view.
- *
- * Uses `pipelines-table-component` to render Pipelines table with an API call.
- * Endpoint is provided in HTML and passed as scope.
- * We need a store to make the request and store the received environemnts.
- *
- * Necessary SVG in the table are provided as props. This should be refactored
- * as soon as we have Webpack and can load them directly into JS files.
  */
 
 $(() => {
@@ -28,94 +17,11 @@ $(() => {
   gl.commits = gl.commits || {};
   gl.commits.pipelines = gl.commits.pipelines || {};
 
-  if (gl.commits.PipelinesTableView) {
-    gl.commits.PipelinesTableView.$destroy(true);
+  if (gl.commits.PipelinesTableBundle) {
+    gl.commits.PipelinesTableBundle.$destroy(true);
   }
 
-  gl.commits.pipelines.PipelinesTableView = new Vue({
-
+  gl.commits.pipelines.PipelinesTableBundle = new gl.commits.pipelines.PipelinesTableView({
     el: document.querySelector('#commit-pipeline-table-view'),
-
-    components: {
-      'pipelines-table-component': gl.pipelines.PipelinesTableComponent,
-    },
-
-    /**
-     * Accesses the DOM to provide the needed data.
-     * Returns the necessary props to render `pipelines-table-component` component.
-     *
-     * @return {Object} Props for `pipelines-table-component`
-     */
-    data() {
-      const pipelinesTableData = document.querySelector('#commit-pipeline-table-view').dataset;
-      const svgsData = document.querySelector('.pipeline-svgs').dataset;
-      const store = gl.commits.pipelines.PipelinesStore.create();
-
-      // Transform svgs DOMStringMap to a plain Object.
-      const svgsObject = Object.keys(svgsData).reduce((acc, element) => {
-        acc[element] = svgsData[element];
-        return acc;
-      }, {});
-
-      return {
-        endpoint: pipelinesTableData.endpoint,
-        svgs: svgsObject,
-        store,
-        state: store.state,
-        isLoading: false,
-        error: false,
-      };
-    },
-
-    /**
-     * When the component is created the service to fetch the data will be
-     * initialized with the correct endpoint.
-     *
-     * A request to fetch the pipelines will be made.
-     * In case of a successfull response we will store the data in the provided
-     * store, in case of a failed response we need to warn the user.
-     *
-     */
-    created() {
-      gl.pipelines.pipelinesService = new PipelinesService(this.endpoint);
-
-      this.isLoading = true;
-
-      return gl.pipelines.pipelinesService.all()
-        .then(response => response.json())
-        .then((json) => {
-          this.store.store(json);
-          this.isLoading = false;
-          this.error = false;
-        }).catch(() => {
-          this.error = true;
-          this.isLoading = false;
-          new Flash('An error occurred while fetching the pipelines.', 'alert');
-        });
-    },
-
-    template: `
-      <div>
-        <div class="pipelines realtime-loading" v-if='isLoading'>
-          <i class="fa fa-spinner fa-spin"></i>
-        </div>
-
-        <div class="blank-state blank-state-no-icon"
-          v-if="!isLoading && !error && state.pipelines.length === 0">
-          <h2 class="blank-state-title js-blank-state-title">
-            You don't have any pipelines.
-          </h2>
-        </div>
-
-        <div
-          class="table-holder pipelines"
-          v-if='!isLoading && state.pipelines.length > 0'>
-          <pipelines-table-component
-            :pipelines='state.pipelines'
-            :svgs='svgs'>
-          </pipelines-table-component>
-        </div>
-      </div>
-    `,
   });
 });
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_service.js.es6 b/app/assets/javascripts/commit/pipelines/pipelines_service.js.es6
index 1e6aa73d9cf..f4ed986b0c5 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_service.js.es6
+++ b/app/assets/javascripts/commit/pipelines/pipelines_service.js.es6
@@ -32,3 +32,8 @@ class PipelinesService {
     return this.pipelines.get();
   }
 }
+
+window.gl = window.gl || {};
+gl.commits = gl.commits || {};
+gl.commits.pipelines = gl.commits.pipelines || {};
+gl.commits.pipelines.PipelinesService = PipelinesService;
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.js.es6 b/app/assets/javascripts/commit/pipelines/pipelines_table.js.es6
new file mode 100644
index 00000000000..df7a6455eed
--- /dev/null
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.js.es6
@@ -0,0 +1,104 @@
+/* eslint-disable no-new, no-param-reassign */
+/* global Vue, CommitsPipelineStore, PipelinesService, Flash */
+
+//= require vue
+//= require vue-resource
+//= require vue_shared/vue_resource_interceptor
+//= require vue_shared/components/pipelines_table
+
+/**
+ *
+ * Uses `pipelines-table-component` to render Pipelines table with an API call.
+ * Endpoint is provided in HTML and passed as `endpoint`.
+ * We need a store to store the received environemnts.
+ * We need a service to communicate with the server.
+ *
+ * Necessary SVG in the table are provided as props. This should be refactored
+ * as soon as we have Webpack and can load them directly into JS files.
+ */
+
+(() => {
+  window.gl = window.gl || {};
+  gl.commits = gl.commits || {};
+  gl.commits.pipelines = gl.commits.pipelines || {};
+
+  gl.commits.pipelines.PipelinesTableView = Vue.component('pipelines-table', {
+
+    components: {
+      'pipelines-table-component': gl.pipelines.PipelinesTableComponent,
+    },
+
+    /**
+     * Accesses the DOM to provide the needed data.
+     * Returns the necessary props to render `pipelines-table-component` component.
+     *
+     * @return {Object}
+     */
+    data() {
+      const pipelinesTableData = document.querySelector('#commit-pipeline-table-view').dataset;
+      const svgsData = document.querySelector('.pipeline-svgs').dataset;
+      const store = gl.commits.pipelines.PipelinesStore.create();
+
+      // Transform svgs DOMStringMap to a plain Object.
+      const svgsObject = Object.keys(svgsData).reduce((acc, element) => {
+        acc[element] = svgsData[element];
+        return acc;
+      }, {});
+
+      return {
+        endpoint: pipelinesTableData.endpoint,
+        svgs: svgsObject,
+        store,
+        state: store.state,
+        isLoading: false,
+      };
+    },
+
+    /**
+     * When the component is created the service to fetch the data will be
+     * initialized with the correct endpoint.
+     *
+     * A request to fetch the pipelines will be made.
+     * In case of a successfull response we will store the data in the provided
+     * store, in case of a failed response we need to warn the user.
+     *
+     */
+    created() {
+      gl.pipelines.pipelinesService = new PipelinesService(this.endpoint);
+
+      this.isLoading = true;
+      return gl.pipelines.pipelinesService.all()
+        .then(response => response.json())
+        .then((json) => {
+          this.store.store(json);
+          this.isLoading = false;
+        }).catch(() => {
+          this.isLoading = false;
+          new Flash('An error occurred while fetching the pipelines.', 'alert');
+        });
+    },
+
+    template: `
+      <div>
+        <div class="pipelines realtime-loading" v-if="isLoading">
+          <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>
+
+        <div class="table-holder pipelines"
+          v-if="!isLoading && state.pipelines.length > 0">
+          <pipelines-table-component
+            :pipelines="state.pipelines"
+            :svgs="svgs">
+          </pipelines-table-component>
+        </div>
+      </div>
+    `,
+  });
+})();
diff --git a/app/assets/javascripts/environments/environments_bundle.js.es6 b/app/assets/javascripts/environments/environments_bundle.js.es6
index 3b003f6f661..cd205617a97 100644
--- a/app/assets/javascripts/environments/environments_bundle.js.es6
+++ b/app/assets/javascripts/environments/environments_bundle.js.es6
@@ -1,7 +1,7 @@
 //= require vue
 //= require_tree ./stores/
 //= require ./components/environment
-//= require ./vue_resource_interceptor
+//= require vue_shared/vue_resource_interceptor
 
 $(() => {
   window.gl = window.gl || {};
diff --git a/app/assets/javascripts/environments/vue_resource_interceptor.js.es6 b/app/assets/javascripts/environments/vue_resource_interceptor.js.es6
deleted file mode 100644
index 406bdbc1c7d..00000000000
--- a/app/assets/javascripts/environments/vue_resource_interceptor.js.es6
+++ /dev/null
@@ -1,12 +0,0 @@
-/* global Vue */
-Vue.http.interceptors.push((request, next) => {
-  Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1;
-
-  next((response) => {
-    if (typeof response.data === 'string') {
-      response.data = JSON.parse(response.data); // eslint-disable-line
-    }
-
-    Vue.activeResources--; // eslint-disable-line
-  });
-});
diff --git a/app/assets/javascripts/vue_pipelines_index/pipelines.js.es6 b/app/assets/javascripts/vue_pipelines_index/pipelines.js.es6
index 34d93ce1b7f..c1daf816060 100644
--- a/app/assets/javascripts/vue_pipelines_index/pipelines.js.es6
+++ b/app/assets/javascripts/vue_pipelines_index/pipelines.js.es6
@@ -1,7 +1,7 @@
 /* global Vue, Turbolinks, gl */
 /* eslint-disable no-param-reassign */
 
-//= require vue_pagination/index
+//= require vue_shared/components/table_pagination
 //= require ./store.js.es6
 //= require vue_shared/components/pipelines_table
 
@@ -9,7 +9,7 @@
   gl.VuePipelines = Vue.extend({
 
     components: {
-      glPagination: gl.VueGlPagination,
+      'gl-pagination': gl.VueGlPagination,
       'pipelines-table-component': gl.pipelines.PipelinesTableComponent,
     },
 
diff --git a/app/assets/javascripts/vue_pipelines_index/time_ago.js.es6 b/app/assets/javascripts/vue_pipelines_index/time_ago.js.es6
index 655110feba1..61417b28630 100644
--- a/app/assets/javascripts/vue_pipelines_index/time_ago.js.es6
+++ b/app/assets/javascripts/vue_pipelines_index/time_ago.js.es6
@@ -1,6 +1,8 @@
 /* global Vue, gl */
 /* eslint-disable no-param-reassign */
 
+//= require lib/utils/datetime_utility
+
 ((gl) => {
   gl.VueTimeAgo = Vue.extend({
     data() {
diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table.js.es6 b/app/assets/javascripts/vue_shared/components/pipelines_table.js.es6
index 4b6bba461d7..9bc1ea65e53 100644
--- a/app/assets/javascripts/vue_shared/components/pipelines_table.js.es6
+++ b/app/assets/javascripts/vue_shared/components/pipelines_table.js.es6
@@ -4,10 +4,9 @@
 //= require ./pipelines_table_row
 
 /**
- * Pipelines Table Component
- *
- * Given an array of pipelines, renders a table.
+ * Pipelines Table Component.
  *
+ * Given an array of objects, renders a table.
  */
 
 (() => {
@@ -20,11 +19,11 @@
       pipelines: {
         type: Array,
         required: true,
-        default: [],
+        default: () => ([]),
       },
 
       /**
-       * Remove this. Find a better way to do this. don't want to provide this 3 times.
+       * TODO: Remove this when we have webpack.
        */
       svgs: {
         type: Object,
@@ -41,19 +40,18 @@
       <table class="table ci-table">
         <thead>
           <tr>
-            <th class="pipeline-status">Status</th>
-            <th class="pipeline-info">Pipeline</th>
-            <th class="pipeline-commit">Commit</th>
-            <th class="pipeline-stages">Stages</th>
-            <th class="pipeline-date"></th>
-            <th class="pipeline-actions hidden-xs"></th>
+            <th class="js-pipeline-status pipeline-status">Status</th>
+            <th class="js-pipeline-info pipeline-info">Pipeline</th>
+            <th class="js-pipeline-commit pipeline-commit">Commit</th>
+            <th class="js-pipeline-stages pipeline-stages">Stages</th>
+            <th class="js-pipeline-date pipeline-date"></th>
+            <th class="js-pipeline-actions pipeline-actions hidden-xs"></th>
           </tr>
         </thead>
         <tbody>
           <template v-for="model in pipelines"
             v-bind:model="model">
-            <tr
-              is="pipelines-table-row-component"
+            <tr is="pipelines-table-row-component"
               :pipeline="model"
               :svgs="svgs"></tr>
           </template>
diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js.es6 b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js.es6
index c0ff0c90e4e..375516e3804 100644
--- a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js.es6
+++ b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js.es6
@@ -7,6 +7,12 @@
 //= require vue_shared/components/commit
 //= require vue_pipelines_index/pipeline_actions
 //= require vue_pipelines_index/time_ago
+
+/**
+ * Pipeline table row.
+ *
+ * Given the received object renders a table row in the pipelines' table.
+ */
 (() => {
   window.gl = window.gl || {};
   gl.pipelines = gl.pipelines || {};
@@ -21,7 +27,7 @@
       },
 
       /**
-       * Remove this. Find a better way to do this. don't want to provide this 3 times.
+       * TODO: Remove this when we have webpack;
        */
       svgs: {
         type: Object,
@@ -32,12 +38,10 @@
 
     components: {
       'commit-component': gl.CommitComponent,
-      runningPipeline: gl.VueRunningPipeline,
-      pipelineActions: gl.VuePipelineActions,
-      'vue-stage': gl.VueStage,
-      pipelineUrl: gl.VuePipelineUrl,
-      pipelineHead: gl.VuePipelineHead,
-      statusScope: gl.VueStatusScope,
+      'pipeline-actions': gl.VuePipelineActions,
+      'dropdown-stage': gl.VueStage,
+      'pipeline-url': gl.VuePipelineUrl,
+      'status-scope': gl.VueStatusScope,
       'time-ago': gl.VueTimeAgo,
     },
 
@@ -46,48 +50,48 @@
        * If provided, returns the commit tag.
        * Needed to render the commit component column.
        *
-       * TODO: Document this logic, need to ask @grzesiek and @selfup
+       * This field needs a lot of verification, because of different possible cases:
+       *
+       * 1. person who is an author of a commit might be a GitLab user
+       * 2. if person who is an author of a commit is a GitLab user he/she can have a GitLab avatar
+       * 3. If GitLab user does not have avatar he/she might have a Gravatar
+       * 4. If committer is not a GitLab User he/she can have a Gravatar
+       * 5. We do not have consistent API object in this case
+       * 6. We should improve API and the code
        *
        * @returns {Object|Undefined}
        */
       commitAuthor() {
-        if (!this.pipeline.commit) {
-          return { avatar_url: '', web_url: '', username: '' };
-        }
+        let commitAuthorInformation;
 
+        // 1. person who is an author of a commit might be a GitLab user
         if (this.pipeline &&
           this.pipeline.commit &&
           this.pipeline.commit.author) {
-          return this.pipeline.commit.author;
+          // 2. if person who is an author of a commit is a GitLab user
+          // he/she can have a GitLab avatar
+          if (this.pipeline.commit.author.avatar_url) {
+            commitAuthorInformation = this.pipeline.commit.author;
+
+            // 3. If GitLab user does not have avatar he/she might have a Gravatar
+          } else if (this.pipeline.commit.author_gravatar_url) {
+            commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, {
+              avatar_url: this.pipeline.commit.author_gravatar_url,
+            });
+          }
         }
 
+        // 4. If committer is not a GitLab User he/she can have a Gravatar
         if (this.pipeline &&
-          this.pipeline.commit &&
-          this.pipeline.commit.author_gravatar_url &&
-          this.pipeline.commit.author_name &&
-          this.pipeline.commit.author_email) {
-          return {
+          this.pipeline.commit) {
+          commitAuthorInformation = {
             avatar_url: this.pipeline.commit.author_gravatar_url,
             web_url: `mailto:${this.pipeline.commit.author_email}`,
             username: this.pipeline.commit.author_name,
           };
         }
 
-        return undefined;
-      },
-
-      /**
-       * Figure this out!
-       * Needed to render the commit component column.
-       */
-      author(pipeline) {
-        if (!pipeline.commit) return { avatar_url: '', web_url: '', username: '' };
-        if (pipeline.commit.author) return pipeline.commit.author;
-        return {
-          avatar_url: pipeline.commit.author_gravatar_url,
-          web_url: `mailto:${pipeline.commit.author_email}`,
-          username: pipeline.commit.author_name,
-        };
+        return commitAuthorInformation;
       },
 
       /**
@@ -108,6 +112,9 @@
        * If provided, returns the commit ref.
        * Needed to render the commit component column.
        *
+       * Matched `url` prop sent in the API to `path` prop needed
+       * in the commit component.
+       *
        * @returns {Object|Undefined}
        */
       commitRef() {
@@ -169,6 +176,17 @@
     },
 
     methods: {
+      /**
+       * FIXME: This should not be in this component but in the components that
+       * need this function.
+       *
+       * Used to render SVGs in the following components:
+       * - status-scope
+       * - dropdown-stage
+       *
+       * @param  {String} string
+       * @return {String}
+       */
       match(string) {
         return string.replace(/_([a-z])/g, (m, w) => w.toUpperCase());
       },
@@ -177,12 +195,12 @@
     template: `
       <tr class="commit">
         <status-scope
-          :pipeline='pipeline'
-          :svgs='svgs'
+          :pipeline="pipeline"
+          :svgs="svgs"
           :match="match">
         </status-scope>
 
-        <pipeline-url :pipeline='pipeline'></pipeline-url>
+        <pipeline-url :pipeline="pipeline"></pipeline-url>
 
         <td>
           <commit-component
@@ -197,14 +215,20 @@
         </td>
 
         <td class="stage-cell">
-          <div class="stage-container dropdown js-mini-pipeline-graph" v-for='stage in pipeline.details.stages'>
-            <vue-stage :stage='stage' :svgs='svgs' :match='match'></vue-stage>
+          <div class="stage-container dropdown js-mini-pipeline-graph"
+            v-if="pipeline.details.stages.length > 0"
+            v-for="stage in pipeline.details.stages">
+            <dropdown-stage
+              :stage="stage"
+              :svgs="svgs"
+              :match="match">
+            </dropdown-stage>
           </div>
         </td>
 
-        <time-ago :pipeline='pipeline' :svgs='svgs'></time-ago>
+        <time-ago :pipeline="pipeline" :svgs="svgs"></time-ago>
 
-        <pipeline-actions :pipeline='pipeline' :svgs='svgs'></pipeline-actions>
+        <pipeline-actions :pipeline="pipeline" :svgs="svgs"></pipeline-actions>
       </tr>
     `,
   });
diff --git a/app/assets/javascripts/vue_pagination/index.js.es6 b/app/assets/javascripts/vue_shared/components/table_pagination.js.es6
similarity index 100%
rename from app/assets/javascripts/vue_pagination/index.js.es6
rename to app/assets/javascripts/vue_shared/components/table_pagination.js.es6
diff --git a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js.es6 b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js.es6
index 54c2b4ad369..d627fa2b88a 100644
--- a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js.es6
+++ b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js.es6
@@ -1,10 +1,15 @@
-/* eslint-disable func-names, prefer-arrow-callback, no-unused-vars */
+/* eslint-disable func-names, prefer-arrow-callback, no-unused-vars,
+no-param-reassign, no-plusplus */
 /* global Vue */
 
 Vue.http.interceptors.push((request, next) => {
   Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1;
 
-  next(function (response) {
-    Vue.activeResources -= 1;
+  next((response) => {
+    if (typeof response.data === 'string') {
+      response.data = JSON.parse(response.data);
+    }
+
+    Vue.activeResources--;
   });
 });
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index b5a7078a3a1..f880a9862c6 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -37,7 +37,6 @@ class Projects::CommitController < Projects::ApplicationController
       format.json do
         render json: PipelineSerializer
           .new(project: @project, user: @current_user)
-          .with_pagination(request, response)
           .represent(@pipelines)
       end
     end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index deb084c2e91..68f6208c2be 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -218,7 +218,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController
       format.json do
         render json: PipelineSerializer
           .new(project: @project, user: @current_user)
-          .with_pagination(request, response)
           .represent(@pipelines)
       end
     end
diff --git a/app/views/projects/merge_requests/_new_submit.html.haml b/app/views/projects/merge_requests/_new_submit.html.haml
index c1f48837e0e..e00ae629e4b 100644
--- a/app/views/projects/merge_requests/_new_submit.html.haml
+++ b/app/views/projects/merge_requests/_new_submit.html.haml
@@ -44,9 +44,9 @@
         = render "projects/merge_requests/show/commits"
       #diffs.diffs.tab-pane
         -# This tab is always loaded via AJAX
-      #pipelines.pipelines.tab-pane
-        //TODO: This needs to make a new request every time is opened!
-        = render "projects/merge_requests/show/pipelines", endpoint: link_to url_for(params)
+      - if @pipelines.any?
+        #pipelines.pipelines.tab-pane
+          = render "projects/merge_requests/show/pipelines", endpoint: link_to url_for(params)
 
   .mr-loading-status
     = spinner
diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml
index 8dfe967a937..f131836058b 100644
--- a/app/views/projects/merge_requests/_show.html.haml
+++ b/app/views/projects/merge_requests/_show.html.haml
@@ -94,7 +94,6 @@
       #commits.commits.tab-pane
         -# This tab is always loaded via AJAX
       #pipelines.pipelines.tab-pane
-        //TODO: This needs to make a new request every time is opened!
         = render 'projects/commit/pipelines_list', endpoint: pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)
       #diffs.diffs.tab-pane
         -# This tab is always loaded via AJAX
diff --git a/spec/javascripts/commit/pipelines/mock_data.js.es6 b/spec/javascripts/commit/pipelines/mock_data.js.es6
new file mode 100644
index 00000000000..5f0f26a013c
--- /dev/null
+++ b/spec/javascripts/commit/pipelines/mock_data.js.es6
@@ -0,0 +1,90 @@
+/* eslint-disable no-unused-vars */
+const pipeline = {
+  id: 73,
+  user: {
+    name: 'Administrator',
+    username: 'root',
+    id: 1,
+    state: 'active',
+    avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+    web_url: 'http://localhost:3000/root',
+  },
+  path: '/root/review-app/pipelines/73',
+  details: {
+    status: {
+      icon: 'icon_status_failed',
+      text: 'failed',
+      label: 'failed',
+      group: 'failed',
+      has_details: true,
+      details_path: '/root/review-app/pipelines/73',
+    },
+    duration: null,
+    finished_at: '2017-01-25T00:00:17.130Z',
+    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/73#build',
+      },
+      path: '/root/review-app/pipelines/73#build',
+      dropdown_path: '/root/review-app/pipelines/73/stage.json?stage=build',
+    }],
+    artifacts: [],
+    manual_actions: [
+      {
+        name: 'stop_review',
+        path: '/root/review-app/builds/1463/play',
+      },
+      {
+        name: 'name',
+        path: '/root/review-app/builds/1490/play',
+      },
+    ],
+  },
+  flags: {
+    latest: true,
+    triggered: false,
+    stuck: false,
+    yaml_errors: false,
+    retryable: true,
+    cancelable: false,
+  },
+  ref:
+  {
+    name: 'master',
+    path: '/root/review-app/tree/master',
+    tag: false,
+    branch: true,
+  },
+  commit: {
+    id: 'fbd79f04fa98717641deaaeb092a4d417237c2e4',
+    short_id: 'fbd79f04',
+    title: 'Update .gitlab-ci.yml',
+    author_name: 'Administrator',
+    author_email: 'admin@example.com',
+    created_at: '2017-01-16T12:13:57.000-05:00',
+    committer_name: 'Administrator',
+    committer_email: 'admin@example.com',
+    message: 'Update .gitlab-ci.yml',
+    author: {
+      name: 'Administrator',
+      username: 'root',
+      id: 1,
+      state: 'active',
+      avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+      web_url: 'http://localhost:3000/root',
+    },
+    author_gravatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+    commit_url: 'http://localhost:3000/root/review-app/commit/fbd79f04fa98717641deaaeb092a4d417237c2e4',
+    commit_path: '/root/review-app/commit/fbd79f04fa98717641deaaeb092a4d417237c2e4',
+  },
+  retry_path: '/root/review-app/pipelines/73/retry',
+  created_at: '2017-01-16T17:13:59.800Z',
+  updated_at: '2017-01-25T00:00:17.132Z',
+};
diff --git a/spec/javascripts/commit/pipelines/pipelines_spec.js.es6 b/spec/javascripts/commit/pipelines/pipelines_spec.js.es6
new file mode 100644
index 00000000000..3bcc0d1eb18
--- /dev/null
+++ b/spec/javascripts/commit/pipelines/pipelines_spec.js.es6
@@ -0,0 +1,107 @@
+/* global pipeline, Vue */
+
+//= require vue
+//= require vue-resource
+//= require flash
+//= require commit/pipelines/pipelines_store
+//= require commit/pipelines/pipelines_service
+//= require commit/pipelines/pipelines_table
+//= require vue_shared/vue_resource_interceptor
+//= require ./mock_data
+
+describe('Pipelines table in Commits and Merge requests', () => {
+  preloadFixtures('pipelines_table');
+
+  beforeEach(() => {
+    loadFixtures('pipelines_table');
+  });
+
+  describe('successfull request', () => {
+    describe('without pipelines', () => {
+      const pipelinesEmptyResponse = (request, next) => {
+        next(request.respondWith(JSON.stringify([]), {
+          status: 200,
+        }));
+      };
+
+      beforeEach(() => {
+        Vue.http.interceptors.push(pipelinesEmptyResponse);
+      });
+
+      afterEach(() => {
+        Vue.http.interceptors = _.without(
+          Vue.http.interceptors, pipelinesEmptyResponse,
+        );
+      });
+
+      it('should render the empty state', (done) => {
+        const component = new gl.commits.pipelines.PipelinesTableView({
+          el: document.querySelector('#commit-pipeline-table-view'),
+        });
+
+        setTimeout(() => {
+          expect(component.$el.querySelector('.js-blank-state-title').textContent).toContain('No pipelines to show');
+          done();
+        }, 1);
+      });
+    });
+
+    describe('with pipelines', () => {
+      const pipelinesResponse = (request, next) => {
+        next(request.respondWith(JSON.stringify([pipeline]), {
+          status: 200,
+        }));
+      };
+
+      beforeEach(() => {
+        Vue.http.interceptors.push(pipelinesResponse);
+      });
+
+      afterEach(() => {
+        Vue.http.interceptors = _.without(
+          Vue.http.interceptors, pipelinesResponse,
+        );
+      });
+
+      it('should render a table with the received pipelines', (done) => {
+        const component = new gl.commits.pipelines.PipelinesTableView({
+          el: document.querySelector('#commit-pipeline-table-view'),
+        });
+
+        setTimeout(() => {
+          expect(component.$el.querySelectorAll('table > tbody > tr').length).toEqual(1);
+          done();
+        }, 0);
+      });
+    });
+  });
+
+  describe('unsuccessfull request', () => {
+    const pipelinesErrorResponse = (request, next) => {
+      next(request.respondWith(JSON.stringify([]), {
+        status: 500,
+      }));
+    };
+
+    beforeEach(() => {
+      Vue.http.interceptors.push(pipelinesErrorResponse);
+    });
+
+    afterEach(() => {
+      Vue.http.interceptors = _.without(
+        Vue.http.interceptors, pipelinesErrorResponse,
+      );
+    });
+
+    it('should render empty state', (done) => {
+      const component = new gl.commits.pipelines.PipelinesTableView({
+        el: document.querySelector('#commit-pipeline-table-view'),
+      });
+
+      setTimeout(() => {
+        expect(component.$el.querySelector('.js-blank-state-title').textContent).toContain('No pipelines to show');
+        done();
+      }, 0);
+    });
+  });
+});
diff --git a/spec/javascripts/commit/pipelines/pipelines_store_spec.js.es6 b/spec/javascripts/commit/pipelines/pipelines_store_spec.js.es6
new file mode 100644
index 00000000000..46a7df3bb21
--- /dev/null
+++ b/spec/javascripts/commit/pipelines/pipelines_store_spec.js.es6
@@ -0,0 +1,31 @@
+//= require vue
+//= require commit/pipelines/pipelines_store
+
+describe('Store', () => {
+  const store = gl.commits.pipelines.PipelinesStore;
+
+  beforeEach(() => {
+    store.create();
+  });
+
+  it('should start with a blank state', () => {
+    expect(store.state.pipelines.length).toBe(0);
+  });
+
+  it('should store an array of pipelines', () => {
+    const pipelines = [
+      {
+        id: '1',
+        name: 'pipeline',
+      },
+      {
+        id: '2',
+        name: 'pipeline_2',
+      },
+    ];
+
+    store.store(pipelines);
+
+    expect(store.state.pipelines.length).toBe(pipelines.length);
+  });
+});
diff --git a/spec/javascripts/fixtures/pipelines_table.html.haml b/spec/javascripts/fixtures/pipelines_table.html.haml
new file mode 100644
index 00000000000..fbe4a434f76
--- /dev/null
+++ b/spec/javascripts/fixtures/pipelines_table.html.haml
@@ -0,0 +1,2 @@
+#commit-pipeline-table-view{ data: { endpoint: "endpoint" } }
+.pipeline-svgs{ data: { "commit_icon_svg": "svg"} }
diff --git a/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js.es6 b/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js.es6
new file mode 100644
index 00000000000..6825de069e4
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js.es6
@@ -0,0 +1,90 @@
+/* global pipeline */
+
+//= require vue
+//= require vue_shared/components/pipelines_table_row
+//= require commit/pipelines/mock_data
+
+describe('Pipelines Table Row', () => {
+  let component;
+  preloadFixtures('static/environments/element.html.raw');
+
+  beforeEach(() => {
+    loadFixtures('static/environments/element.html.raw');
+
+    component = new gl.pipelines.PipelinesTableRowComponent({
+      el: document.querySelector('.test-dom-element'),
+      propsData: {
+        pipeline,
+        svgs: {},
+      },
+    });
+  });
+
+  it('should render a table row', () => {
+    expect(component.$el).toEqual('TR');
+  });
+
+  describe('status column', () => {
+    it('should render a pipeline link', () => {
+      expect(
+        component.$el.querySelector('td.commit-link a').getAttribute('href'),
+      ).toEqual(pipeline.path);
+    });
+
+    it('should render status text', () => {
+      expect(
+        component.$el.querySelector('td.commit-link a').textContent,
+      ).toContain(pipeline.details.status.text);
+    });
+  });
+
+  describe('information column', () => {
+    it('should render a pipeline link', () => {
+      expect(
+        component.$el.querySelector('td:nth-child(2) a').getAttribute('href'),
+      ).toEqual(pipeline.path);
+    });
+
+    it('should render pipeline ID', () => {
+      expect(
+        component.$el.querySelector('td:nth-child(2) a > span').textContent,
+      ).toEqual(`#${pipeline.id}`);
+    });
+
+    describe('when a user is provided', () => {
+      it('should render user information', () => {
+        expect(
+          component.$el.querySelector('td:nth-child(2) a:nth-child(3)').getAttribute('href'),
+        ).toEqual(pipeline.user.web_url);
+
+        expect(
+          component.$el.querySelector('td:nth-child(2) img').getAttribute('title'),
+        ).toEqual(pipeline.user.name);
+      });
+    });
+  });
+
+  describe('commit column', () => {
+    it('should render link to commit', () => {
+      expect(
+        component.$el.querySelector('td:nth-child(3) .commit-id').getAttribute('href'),
+      ).toEqual(pipeline.commit.commit_path);
+    });
+  });
+
+  describe('stages column', () => {
+    it('should render an icon for each stage', () => {
+      expect(
+        component.$el.querySelectorAll('td:nth-child(4) .js-builds-dropdown-button').length,
+      ).toEqual(pipeline.details.stages.length);
+    });
+  });
+
+  describe('actions column', () => {
+    it('should render the provided actions', () => {
+      expect(
+        component.$el.querySelectorAll('td:nth-child(6) ul li').length,
+      ).toEqual(pipeline.details.manual_actions.length);
+    });
+  });
+});
diff --git a/spec/javascripts/vue_shared/components/pipelines_table_spec.js.es6 b/spec/javascripts/vue_shared/components/pipelines_table_spec.js.es6
new file mode 100644
index 00000000000..cb1006d44dc
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/pipelines_table_spec.js.es6
@@ -0,0 +1,67 @@
+/* global pipeline */
+
+//= require vue
+//= require vue_shared/components/pipelines_table
+//= require commit/pipelines/mock_data
+//= require lib/utils/datetime_utility
+
+describe('Pipelines Table', () => {
+  preloadFixtures('static/environments/element.html.raw');
+
+  beforeEach(() => {
+    loadFixtures('static/environments/element.html.raw');
+  });
+
+  describe('table', () => {
+    let component;
+    beforeEach(() => {
+      component = new gl.pipelines.PipelinesTableComponent({
+        el: document.querySelector('.test-dom-element'),
+        propsData: {
+          pipelines: [],
+          svgs: {},
+        },
+      });
+    });
+
+    it('should render a table', () => {
+      expect(component.$el).toEqual('TABLE');
+    });
+
+    it('should render table head with correct columns', () => {
+      expect(component.$el.querySelector('th.js-pipeline-status').textContent).toEqual('Status');
+      expect(component.$el.querySelector('th.js-pipeline-info').textContent).toEqual('Pipeline');
+      expect(component.$el.querySelector('th.js-pipeline-commit').textContent).toEqual('Commit');
+      expect(component.$el.querySelector('th.js-pipeline-stages').textContent).toEqual('Stages');
+      expect(component.$el.querySelector('th.js-pipeline-date').textContent).toEqual('');
+      expect(component.$el.querySelector('th.js-pipeline-actions').textContent).toEqual('');
+    });
+  });
+
+  describe('without data', () => {
+    it('should render an empty table', () => {
+      const component = new gl.pipelines.PipelinesTableComponent({
+        el: document.querySelector('.test-dom-element'),
+        propsData: {
+          pipelines: [],
+          svgs: {},
+        },
+      });
+      expect(component.$el.querySelectorAll('tbody tr').length).toEqual(0);
+    });
+  });
+
+  describe('with data', () => {
+    it('should render rows', () => {
+      const component = new gl.pipelines.PipelinesTableComponent({
+        el: document.querySelector('.test-dom-element'),
+        propsData: {
+          pipelines: [pipeline],
+          svgs: {},
+        },
+      });
+
+      expect(component.$el.querySelectorAll('tbody tr').length).toEqual(1);
+    });
+  });
+});
diff --git a/spec/javascripts/vue_pagination/pagination_spec.js.es6 b/spec/javascripts/vue_shared/components/table_pagination_spec.js.es6
similarity index 98%
rename from spec/javascripts/vue_pagination/pagination_spec.js.es6
rename to spec/javascripts/vue_shared/components/table_pagination_spec.js.es6
index efb11211ce2..6a0fec43d2e 100644
--- a/spec/javascripts/vue_pagination/pagination_spec.js.es6
+++ b/spec/javascripts/vue_shared/components/table_pagination_spec.js.es6
@@ -1,6 +1,7 @@
 //= require vue
 //= require lib/utils/common_utils
-//= require vue_pagination/index
+//= require vue_shared/components/table_pagination
+/* global fixture, gl */
 
 describe('Pagination component', () => {
   let component;
-- 
GitLab