GH-2107: Add a progress bar for the overall status of the file uploads.
diff --git a/jena-fuseki2/jena-fuseki-ui/src/services/mock/json-server.js b/jena-fuseki2/jena-fuseki-ui/src/services/mock/json-server.js
index b49f5dd..5f20e1d 100644
--- a/jena-fuseki2/jena-fuseki-ui/src/services/mock/json-server.js
+++ b/jena-fuseki2/jena-fuseki-ui/src/services/mock/json-server.js
@@ -260,6 +260,15 @@
     .send(dataContent)
 })
 
+let failUpload = false
+
+// Upload data.
+server.post('/:datasetName/data', (req, res) => {
+  res
+    .status(200)
+    .send()
+})
+
 // PING
 // GET PING STATUS
 server.get('/\\$/ping', (req, res) => {
diff --git a/jena-fuseki2/jena-fuseki-ui/src/views/dataset/Upload.vue b/jena-fuseki2/jena-fuseki-ui/src/views/dataset/Upload.vue
index dc9c87a..3f75631 100644
--- a/jena-fuseki2/jena-fuseki-ui/src/views/dataset/Upload.vue
+++ b/jena-fuseki2/jena-fuseki-ui/src/views/dataset/Upload.vue
@@ -35,7 +35,7 @@
             </div>
             <div class="row">
               <div class="col-sm-12">
-                <h3>Upload files</h3>
+                <h3>Upload files {{ postActionUrl }}</h3>
                 <p>
                   Load data into the default graph of the currently selected dataset, or the given named graph.
                   You may upload any RDF format, such as Turtle, RDF/XML or TRiG.
@@ -100,7 +100,7 @@
                         v-if="!$refs.upload || !$refs.upload.active"
                         @click.prevent="uploadAll()"
                         type="button"
-                        class="btn btn-primary ms-2 d-inline"
+                        class="btn btn-primary ms-2 d-inline upload-files"
                       >
                         <FontAwesomeIcon icon="upload" />
                         <span class="ms-2">upload all</span>
@@ -119,6 +119,30 @@
                       </div>
                     </div>
                   </div>
+                  <div class="pt-2 pb-2">
+                    <div class="progress" style="height: 1.5rem;">
+                      <div
+                        class="progress-bar"
+                        role="progressbar"
+                        :style="`width: ${uploadSucceededPercentage}%`"
+                        :aria-valuenow="uploadSucceededPercentage"
+                        aria-valuemin="0"
+                        aria-valuemax="100"
+                      >
+                        {{ uploadSucceededCount }}/{{ uploadCount }}
+                      </div>
+                      <div
+                        class="progress-bar bg-danger"
+                        role="progressbar"
+                        :style="`width: ${uploadFailedPercentage}%`"
+                        :aria-valuenow="uploadFailedPercentage"
+                        aria-valuemin="0"
+                        aria-valuemax="100"
+                      >
+                        {{ uploadFailedCount }}/{{ uploadCount }}
+                      </div>
+                    </div>
+                  </div>
                 </form>
               </div>
             </div>
@@ -161,7 +185,7 @@
                     <button
                       @click.prevent="data.item.success || data.item.error === 'compressing' ? false : $refs.upload.update(data.item, {active: true})"
                       type="button"
-                      class="btn btn-outline-primary me-0 mb-2 d-block"
+                      class="btn btn-outline-primary me-0 mb-2 d-block upload-file"
                     >
                       <FontAwesomeIcon icon="upload" />
                       <span class="ms-2">upload now</span>
@@ -169,7 +193,7 @@
                     <button
                       @click.prevent="remove(data.item)"
                       type="button"
-                      class="btn btn-outline-primary me-0 mb-md-0 d-block d-md-inline-block"
+                      class="btn btn-outline-primary me-0 mb-md-0 d-block d-md-inline-block remove-file"
                     >
                       <FontAwesomeIcon icon="minus-circle" />
                       <span class="ms-2">remove</span>
@@ -254,7 +278,7 @@
         //   name: ''
         // }
       },
-      datasetTableFields: [
+      datasetTableFields: Object.freeze([
         {
           key: 'name',
           label: 'name',
@@ -279,7 +303,7 @@
           key: 'actions',
           label: 'actions'
         }
-      ]
+      ])
     }
   },
 
@@ -302,6 +326,36 @@
       const params = (this.datasetGraphName && this.datasetGraphName !== '') ? `?graph=${this.datasetGraphName}` : ''
       const dataEndpoint = this.services['gsp-rw']['srv.endpoints'].find(endpoint => endpoint !== '') || ''
       return this.$fusekiService.getFusekiUrl(`/${this.datasetName}/${dataEndpoint}${params}`)
+    },
+    uploadCount () {
+      if (!this.upload || !this.upload.files) {
+        return 0
+      }
+      return this.upload.files.length
+    },
+    uploadSucceededCount () {
+      if (!this.upload || !this.upload.files) {
+        return 0
+      }
+      return this.upload.files.filter(f => Boolean(f.success)).length
+    },
+    uploadFailedCount () {
+      if (!this.upload || !this.upload.files) {
+        return 0
+      }
+      return this.upload.files.filter(f => Boolean(f.error)).length
+    },
+    uploadFailedPercentage () {
+      if (this.uploadCount === 0) {
+        return 0
+      }
+      return (this.uploadFailedCount / this.uploadCount) * 100
+    },
+    uploadSucceededPercentage () {
+      if (this.uploadCount === 0) {
+        return 0
+      }
+      return (this.uploadSucceededCount / this.uploadCount) * 100
     }
   },
 
diff --git a/jena-fuseki2/jena-fuseki-ui/src/views/manage/ExistingDatasets.vue b/jena-fuseki2/jena-fuseki-ui/src/views/manage/ExistingDatasets.vue
index 59c3991..0ea6b32 100644
--- a/jena-fuseki2/jena-fuseki-ui/src/views/manage/ExistingDatasets.vue
+++ b/jena-fuseki2/jena-fuseki-ui/src/views/manage/ExistingDatasets.vue
@@ -44,7 +44,7 @@
                       <button
                         @click="$router.push(`/dataset${data.item.name}/query`)"
                         type="button"
-                        class="btn btn-primary me-0 me-md-2 mb-2 mb-md-0 d-block d-md-inline-block"
+                        class="btn btn-primary me-0 me-md-2 mb-2 mb-md-0 d-block d-md-inline-block dataset-query"
                       >
                         <FontAwesomeIcon icon="question-circle" />
                         <span class="ms-1">query</span>
@@ -81,7 +81,7 @@
                         @click="showPopover(`delete-dataset-${data.item.name}`)"
                         type="button"
                         href="#"
-                        class="btn btn-primary me-0 me-md-2 mb-2 mb-md-0 d-block d-md-inline-block"
+                        class="btn btn-primary me-0 me-md-2 mb-2 mb-md-0 d-block d-md-inline-block dataset-remove"
                       >
                         <FontAwesomeIcon icon="times-circle" />
                         <span class="ms-1">remove</span>
@@ -118,7 +118,7 @@
                         @click="showPopover(`backup-dataset-${data.item.name}`)"
                         type="button"
                         href="#"
-                        class="btn btn-primary me-0 me-md-2 me-0 mb-2 mb-md-0 d-block d-md-inline-block"
+                        class="btn btn-primary me-0 me-md-2 me-0 mb-2 mb-md-0 d-block d-md-inline-block dataset-backup"
                       >
                         <FontAwesomeIcon icon="download" />
                         <span class="ms-1">backup</span>
@@ -126,7 +126,7 @@
                       <button
                         @click="$router.push(`/dataset${data.item.name}/upload`)"
                         type="button"
-                        class="btn btn-primary me-0 me-md-2 me-0 mb-2 mb-md-0 d-block d-md-inline-block"
+                        class="btn btn-primary me-0 me-md-2 me-0 mb-2 mb-md-0 d-block d-md-inline-block dataset-add-data"
                       >
                         <FontAwesomeIcon icon="upload" />
                         <span class="ms-1">add data</span>
@@ -134,7 +134,7 @@
                       <button
                         @click="$router.push(`/dataset${data.item.name}/info`)"
                         type="button"
-                        class="btn btn-primary me-0 mb-md-0 d-block d-md-inline-block"
+                        class="btn btn-primary me-0 mb-md-0 d-block d-md-inline-block dataset-info"
                       >
                         <FontAwesomeIcon icon="tachometer-alt" />
                         <span class="ms-1">info</span>
diff --git a/jena-fuseki2/jena-fuseki-ui/tests/e2e/specs/upload.cy.js b/jena-fuseki2/jena-fuseki-ui/tests/e2e/specs/upload.cy.js
new file mode 100644
index 0000000..c31d1cc
--- /dev/null
+++ b/jena-fuseki2/jena-fuseki-ui/tests/e2e/specs/upload.cy.js
@@ -0,0 +1,155 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Tests the upload view.
+ *
+ * Instead of injecting test data, we use the Manage New view to
+ * add new datasets. So we also cover parts of the Manage view.
+ */
+
+describe('upload', () => {
+  beforeEach(() => {
+    // Intercept new dataset request.
+    cy.intercept('POST', '/$/datasets').as('createDataset')
+    // Special endpoint that clears the datasets data.
+    cy.request('/tests/reset')
+    // Create a sample dataset.
+    cy
+      .visit('/#/manage/new')
+      .then(() => {
+        cy
+          .get('#dataset-name')
+          .type('skosmos')
+        cy
+          .get('#data-set-type-mem')
+          .click()
+        cy
+          .get('button[type="submit"]')
+          .click()
+        // We are redirected to the Manage datasets view.
+        cy
+          .get('table.jena-table')
+          .should('be.visible')
+      })
+    // Wait for the create dataset request to be processed and have a return status, then continue.
+    cy.wait('@createDataset')
+
+    cy.intercept('/$/server').as('server')
+    // We wait until the route navigation guard is called, so that the
+    // view has the dataset information loaded.
+    cy.visit('/#/dataset/skosmos/upload')
+    cy.wait('@server')
+    cy.intercept('/$/server').as('server')
+  })
+  afterEach(() => {
+    // Special endpoint that clears the datasets data.
+    cy.request('/tests/reset')
+  })
+  it('displays an empty progress bar by default', () => {
+    // The progress is present.
+    cy
+      .get('.progress')
+      .should('be.visible')
+    // We have two inner progress bars.
+    cy
+      .get('.progress-bar')
+      .should('have.length', 2)
+    // And each progress bar has the current value set to zero.
+    cy
+      .get('.progress-bar')
+      .each(($el) => {
+        cy
+          .wrap($el)
+          .should('have.attr', 'aria-valuenow', 0)
+      })
+  })
+  it('displays the progress for success and failure', () => {
+    // Intercept upload calls.
+    // Fails every other upload.
+    let fail = false
+    cy.intercept('POST', '/skosmos/data', (req) => {
+      console.log('in upload request')
+      console.log(req)
+      const statusCode = fail ? 500 : 200
+      fail = !fail
+      req.reply({
+        statusCode
+      })
+    }).as('upload')
+    cy
+      .get('input[type=file]')
+      .should('exist')
+    // Prepare three files to be uploaded (Second will fail! See intercept above!).
+    const NUMBER_OF_FILES = 3
+    for (let idx = 0; idx < NUMBER_OF_FILES; idx++) {
+      cy
+        .get('input[type=file]')
+        .selectFile({
+            contents: Cypress.Buffer.from(`@prefix ex:   <http://test.com'onclick=alert(123);'> .
+  ex:ABC a ex:DEF .`),
+            fileName: `file${idx}.ttl`,
+            lastModified: Date.now(),
+          },
+          {
+            force: true
+          })
+    }
+    // We have three files, the json-server handler is programmed to
+    // fail every other time, so we will have 2 successes and one failure.
+    cy
+      .get('button.upload-file')
+      .each(($el) => {
+        cy
+          .wrap($el)
+          .click({force: true})
+        cy.wait('@upload')
+      })
+    // Overall progress now shows 2/3 success, 1/3 failure.
+    cy
+      .get('.progress')
+      .eq(0)
+      .find('.progress-bar')
+      .eq(0)
+      .should('have.text', '2/3')
+    cy
+      .get('.progress')
+      .eq(0)
+      .find('.progress-bar')
+      .eq(1)
+      .should('have.text', '1/3')
+    // First and third files are shown as success, second is failure.
+    cy
+      .get('.progress')
+      .eq(1)
+      .find('.progress-bar')
+      .eq(0)
+      .should('have.class', 'bg-success')
+    cy
+      .get('.progress')
+      .eq(2)
+      .find('.progress-bar')
+      .eq(0)
+      .should('have.class', 'bg-danger')
+    cy
+      .get('.progress')
+      .eq(3)
+      .find('.progress-bar')
+      .eq(0)
+      .should('have.class', 'bg-success')
+  })
+})