This is an automated email from the ASF dual-hosted git repository.

afs pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/jena.git


The following commit(s) were added to refs/heads/main by this push:
     new 578ae5ed2f GH-2162: Add more detailed logging to upload errors.
578ae5ed2f is described below

commit 578ae5ed2f76ee4ab129f3f9d6b212dc7c1414ea
Author: Thomas Thelen <[email protected]>
AuthorDate: Tue Jul 7 09:09:48 2026 -0700

    GH-2162: Add more detailed logging to upload errors.
---
 .../jena-fuseki-ui/src/views/dataset/Upload.vue    |  84 +++++++++++----
 .../tests/unit/views/dataset/upload.vue.spec.js    | 118 +++++++++++++++++++++
 2 files changed, 183 insertions(+), 19 deletions(-)

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 95adc97cf8..052b348661 100644
--- a/jena-fuseki2/jena-fuseki-ui/src/views/dataset/Upload.vue
+++ b/jena-fuseki2/jena-fuseki-ui/src/views/dataset/Upload.vue
@@ -182,6 +182,9 @@
                       {{ data.item.response.tripleCount }}
                     </span>
                     <span v-else class="small">0</span>
+                    <div v-if="data.item.error" class="small text-danger 
upload-error-message">
+                      {{ getUploadErrorMessage(data.item) }}
+                    </div>
                   </template>
                   <template #cell(actions)="data">
                     <button
@@ -368,7 +371,7 @@ export default {
     },
     upload: {
       handler () {
-        this.validateFiles()
+        this.validateFiles(false)
       },
       deep: true,
       immediate: false
@@ -425,29 +428,72 @@ export default {
       this.graphNameClasses = ['form-control', formValidationClass]
       return isValidGraphName
     },
-    validateFiles () {
+    /**
+     * Validate the selected upload files and update fileUploadClasses to 
reflect the result.
+     *
+     * @param {boolean} explicit - Whether this call comes from an explicit 
user action such as a
+     *   submit attempt. When true, an empty file list is flagged invalid. 
When false (e.g. a passive
+     *   change like removing a file), an empty list is only flagged invalid 
if it already was.
+     * @return {boolean} - true if at least one file is selected.
+     */
+    validateFiles (explicit = true) {
       if (this.upload.files !== null && this.upload.files.length > 0) {
-        this.fileUploadClasses = [
-          'btn',
-          'btn-success',
-          'is-valid'
-        ]
-        return true
+        this.fileUploadClasses = ['btn', 'btn-success', 'is-valid']
+      } else if (explicit || this.fileUploadClasses.includes('is-invalid')) {
+        this.fileUploadClasses = ['btn', 'btn-success', 'is-invalid']
+      } else {
+        this.fileUploadClasses = ['btn', 'btn-success']
       }
-      this.fileUploadClasses = [
-        'btn',
-        'btn-success',
-        'is-invalid'
-      ]
-      return false
+      return this.fileUploadClasses.includes('is-valid')
     },
+    /**
+     * Upload a single file via the vue-upload-component instance, surfacing 
any failure
+     * as a displayed error instead of an unhandled rejection.
+     *
+     * @param {object} file - The vue-upload-component file entry being 
uploaded.
+     * @param {object} component - The vue-upload-component instance (as 
passed to custom-action).
+     * @return {Promise<*>} - The resolved response from uploadHtml5, or 
undefined on failure.
+     */
     async handleUploadWithErrorHandling (file, component) {
       try {
-        return component
-          .uploadHtml5(file)
-          .catch(error => displayError(this, error))
-      } catch (error) {
-        displayError(this, error)
+        return await component.uploadHtml5(file)
+      } catch {
+        const uploadedFile = component.get(file) || file
+        displayError(this, this.getUploadErrorMessage(uploadedFile))
+      }
+    },
+    /**
+     * Build a human-readable error message for a failed upload file.
+     *
+     * Prefers the server's detailed error response body (plain text or a JSON 
object with
+     * a "message" field) over the generic vue-upload-component file.error 
code, falling
+     * back to a readable message per error code when no response body is 
available.
+     *
+     * @param {object} file - The vue-upload-component file entry, expected to 
carry
+     *   `error` (a vue-upload-component error code) and/or `response` (the 
server's body).
+     * @return {string} - A human-readable error message.
+     */
+    getUploadErrorMessage (file) {
+      const response = file && file.response
+      if (typeof response === 'string' && response.trim() !== '') {
+        return response.trim()
+      }
+      if (response && typeof response === 'object' && response.message) {
+        return response.message
+      }
+      switch (file && file.error) {
+        case 'network':
+          return 'Upload failed: could not reach the server. Please check your 
connection and try again.'
+        case 'timeout':
+          return 'Upload failed: the request timed out.'
+        case 'abort':
+          return 'Upload was cancelled.'
+        case 'denied':
+          return 'Upload was rejected by the server.'
+        case 'server':
+          return 'Upload failed: the server encountered an error while 
processing the file.'
+        default:
+          return 'Upload failed.'
       }
     }
   }
diff --git 
a/jena-fuseki2/jena-fuseki-ui/tests/unit/views/dataset/upload.vue.spec.js 
b/jena-fuseki2/jena-fuseki-ui/tests/unit/views/dataset/upload.vue.spec.js
new file mode 100644
index 0000000000..4c06ec7667
--- /dev/null
+++ b/jena-fuseki2/jena-fuseki-ui/tests/unit/views/dataset/upload.vue.spec.js
@@ -0,0 +1,118 @@
+/**
+ * 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.
+ */
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+import Upload from '@/views/dataset/Upload.vue'
+import { describe, it, expect, vi } from 'vitest'
+
+const mountFunction = (options) => {
+  return mount(Upload, Object.assign(options || {}, {
+    shallow: true,
+    props: {
+      datasetName: 'test-ds'
+    },
+    global: {
+      mocks: {
+        $route: { params: { datasetName: 'test-ds' } },
+        $fusekiService: {
+          getFusekiUrl: (path) => `http://localhost:3030${path}`,
+          getDatasetServices: vi.fn().mockResolvedValue({ 'gsp-rw': { 
'srv.endpoints': ['data'] } })
+        }
+      }
+    }
+  }))
+}
+
+describe('Upload.vue', () => {
+  describe('getFileStatus', () => {
+    it('returns "danger" for a file with an error', () => {
+      const wrapper = mountFunction()
+      expect(wrapper.vm.getFileStatus({ error: 'some error', success: false, 
active: false })).toBe('danger')
+    })
+
+    it('returns "success" for a successfully uploaded file', () => {
+      const wrapper = mountFunction()
+      expect(wrapper.vm.getFileStatus({ error: null, success: true, active: 
false })).toBe('success')
+    })
+
+    it('returns "primary" for an active upload', () => {
+      const wrapper = mountFunction()
+      expect(wrapper.vm.getFileStatus({ error: null, success: false, active: 
true })).toBe('primary')
+    })
+
+    it('returns empty string for a file with no status', () => {
+      const wrapper = mountFunction()
+      expect(wrapper.vm.getFileStatus({ error: null, success: false, active: 
false })).toBe('')
+    })
+  })
+
+  describe('getUploadErrorMessage', () => {
+    it('returns the server\'s detailed plain-text error response, when 
present', () => {
+      const wrapper = mountFunction()
+      const file = { error: 'denied', response: '  Parse Error: [line: 3, col: 
5] Triples not terminated by \'.\'  ' }
+      expect(wrapper.vm.getUploadErrorMessage(file)).toBe('Parse Error: [line: 
3, col: 5] Triples not terminated by \'.\'')
+    })
+
+    it('returns the message field from a JSON error response, when present', 
() => {
+      const wrapper = mountFunction()
+      const file = { error: 'denied', response: { message: 'Unsupported Media 
Type' } }
+      expect(wrapper.vm.getUploadErrorMessage(file)).toBe('Unsupported Media 
Type')
+    })
+
+    it('falls back to a readable message for a generic "server" error code', 
() => {
+      const wrapper = mountFunction()
+      const file = { error: 'server', response: {} }
+      expect(wrapper.vm.getUploadErrorMessage(file)).toBe('Upload failed: the 
server encountered an error while processing the file.')
+    })
+
+    it('falls back to a readable message for a generic "network" error code', 
() => {
+      const wrapper = mountFunction()
+      const file = { error: 'network', response: {} }
+      expect(wrapper.vm.getUploadErrorMessage(file)).toBe('Upload failed: 
could not reach the server. Please check your connection and try again.')
+    })
+
+    it('returns a generic message when there is no file', () => {
+      const wrapper = mountFunction()
+      expect(wrapper.vm.getUploadErrorMessage(null)).toBe('Upload failed.')
+    })
+  })
+
+  describe('file list validation', () => {
+    it('does not flag the form invalid just from removing the last (failed) 
file', async () => {
+      const wrapper = mountFunction()
+      await nextTick()
+
+      wrapper.vm.upload.files = [{ id: '1', name: 'broken.ttl', error: null, 
success: false, active: false, response: {} }]
+      await nextTick()
+      expect(wrapper.vm.fileUploadClasses).toEqual(['btn', 'btn-success', 
'is-valid'])
+
+      wrapper.vm.upload.files = [{ id: '1', name: 'broken.ttl', error: 
'denied', success: false, active: false, response: 'Parse Error' }]
+      await nextTick()
+      expect(wrapper.vm.fileUploadClasses).toEqual(['btn', 'btn-success', 
'is-valid'])
+
+      wrapper.vm.upload.files = []
+      await nextTick()
+      expect(wrapper.vm.fileUploadClasses).toEqual(['btn', 'btn-success'])
+    })
+
+    it('still flags the form invalid on an explicit submit attempt with no 
files', () => {
+      const wrapper = mountFunction()
+      expect(wrapper.vm.validateFiles()).toBe(false)
+      expect(wrapper.vm.fileUploadClasses).toEqual(['btn', 'btn-success', 
'is-invalid'])
+    })
+  })
+})

Reply via email to