diff --git a/src/js/models/SolrResult.js b/src/js/models/SolrResult.js index 4bf2cb00a..fd8c2ddeb 100644 --- a/src/js/models/SolrResult.js +++ b/src/js/models/SolrResult.js @@ -1,11 +1,11 @@ -define(["jquery", "underscore", "backbone"], function ($, _, Backbone) { +define(["jquery", "underscore", "backbone"], ($, _, Backbone) => { /** * @class SolrResult * @classdesc A single result from the Solr search service * @classcategory Models * @extends Backbone.Model */ - var SolrResult = Backbone.Model.extend( + const SolrResult = Backbone.Model.extend( /** @lends SolrResult.prototype */ { // This model contains all of the attributes found in the SOLR 'docs' field inside of the SOLR response element defaults: { @@ -278,103 +278,96 @@ define(["jquery", "underscore", "backbone"], function ($, _, Backbone) { ); }, - /* - * This method will download this object while sending the user's auth token in the request. + /** + * Download this object while sending the user's auth token in the + * request. */ - downloadWithCredentials: function () { - //if(this.get("isPublic")) return; - - //Get info about this object - var url = this.get("url"), - model = this; - - //Create an XHR - var xhr = new XMLHttpRequest(); + async downloadWithCredentials() { + const model = this; - //Open and send the request with the user's auth token - xhr.open("GET", url); + // Call the new getBlob method and handle the response + const response = await this.fetchDataObjectWithCredentials(); + const blob = await response.blob(); + const filename = this.getFileNameFromResponse(response); - if (MetacatUI.appUserModel.get("loggedIn")) xhr.withCredentials = true; - - //When the XHR is ready, create a link with the raw data (Blob) and click the link to download - xhr.onload = function () { - if (this.status == 404) { - this.onerror.call(this); - return; - } - - //Get the file name to save this file as - var filename = xhr.getResponseHeader("Content-Disposition"); - - if (!filename) { - filename = - model.get("fileName") || - model.get("title") || - model.get("id") || - "download"; - } else - filename = filename - .substring(filename.indexOf("filename=") + 9) - .replace(/"/g, ""); - - //Replace any whitespaces - filename = filename.trim().replace(/ /g, "_"); - - //For IE, we need to use the navigator API - if (navigator && navigator.msSaveOrOpenBlob) { - navigator.msSaveOrOpenBlob(xhr.response, filename); - } - //Other browsers can download it via a link - else { - var a = document.createElement("a"); - a.href = window.URL.createObjectURL(xhr.response); // xhr.response is a blob - - // Set the file name. - a.download = filename; - - a.style.display = "none"; - document.body.appendChild(a); - a.click(); - a.remove(); - } - - model.trigger("downloadComplete"); - - // Track this event - MetacatUI.analytics?.trackEvent( - "download", - "Download DataONEObject", - model.get("id"), - ); - }; - - xhr.onerror = function (e) { - model.trigger("downloadError"); + // For IE, we need to use the navigator API + if (navigator && navigator.msSaveOrOpenBlob) { + navigator.msSaveOrOpenBlob(blob, filename); + } else { + // Other browsers can download it via a link + const a = document.createElement("a"); + a.href = window.URL.createObjectURL(blob); + a.download = filename; + a.style.display = "none"; + document.body.appendChild(a); + a.click(); + a.remove(); + } - // Track the error - MetacatUI.analytics?.trackException( - `Download DataONEObject error: ${e || ""}`, - model.get("id"), - true, - ); - }; + // Track this event + model.trigger("downloadComplete"); + MetacatUI.analytics?.trackEvent( + "download", + "Download DataONEObject", + model.get("id"), + ); + }, - xhr.onprogress = function (e) { - if (e.lengthComputable) { - var percent = (e.loaded / e.total) * 100; - model.set("downloadPercent", percent); + /** + * This method will fetch this object while sending the user's auth token + * in the request. The data can then be downloaded or displayed in the + * browser + * @returns {Promise} A promise that resolves when the data is fetched + * @since 0.0.0 + */ + fetchDataObjectWithCredentials() { + const url = this.get("url"); + const token = MetacatUI.appUserModel.get("token") || ""; + const method = "GET"; + + return new Promise((resolve, reject) => { + const headers = {}; + if (token) { + headers.Authorization = `Bearer ${token}`; } - }; - - xhr.responseType = "blob"; - if (MetacatUI.appUserModel.get("loggedIn")) - xhr.setRequestHeader( - "Authorization", - "Bearer " + MetacatUI.appUserModel.get("token"), - ); + fetch(url, { method, headers }) + .then((response) => { + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.statusText}`); + } + resolve(response); + }) + .catch((error) => { + reject(error); + }); + }); + }, - xhr.send(); + /** + * Get the filename from the response headers or default to the model's + * title, id, or "download" + * @param {Response} response - The response object from the fetch request + * @returns {string} The filename to save the file as + * @since 0.0.0 + */ + getFileNameFromResponse(response) { + const model = this; + let filename = response.headers.get("Content-Disposition"); + + if (!filename) { + filename = + model.get("fileName") || + model.get("title") || + model.get("id") || + "download"; + } else { + filename = filename + .substring(filename.indexOf("filename=") + 9) + .replace(/"/g, ""); + } + filename = filename.trim().replace(/ /g, "_"); + return filename; }, getInfo: function (fields) { diff --git a/src/js/views/DataObjectView.js b/src/js/views/DataObjectView.js new file mode 100644 index 000000000..569d0cb6d --- /dev/null +++ b/src/js/views/DataObjectView.js @@ -0,0 +1,85 @@ +"use strict"; + +define(["backbone", "views/TableEditorView"], (Backbone, TableEditorView) => { + // The base class for the view + const BASE_CLASS = "object-view"; + + /** + * @class DataObjectView + * @classdesc A view that downloads and displays a DataONE object. Currently + * there is support for displaying CSV files as a table. + * @classcategory Views + * @augments Backbone.View + * @class + * @since 0.0.0 + * @screenshot views/DataObjectView.png //TODO + */ + const DataObjectView = Backbone.View.extend( + /** @lends DataObjectView.prototype */ + { + /** @inheritdoc */ + type: "DataObjectView", + + /** @inheritdoc */ + className: BASE_CLASS, + + /** @inheritdoc */ + tagName: "div", + + /** + * Initializes the DataObjectView + * @param {object} options - Options for the view + * @param {SolrResult} options.model - A SolrResult model + */ + initialize(options) { + this.model = options.model; + // TODO: We get format from the response headers, should we compare it, + // or prevent downloading the object if it's not a supported type? + // this.format = this.model.get("formatId") || + // this.model.get("mediaType"); + }, + + /** @inheritdoc */ + render() { + this.$el.empty(); + this.downloadObject().then((response) => this.renderObject(response)); + return this; + }, + + /** + * With the already fetched DataONE object, check the format and render + * the object accordingly. + * @param {Response} response - The response from the DataONE object API + */ + renderObject(response) { + const format = response.headers.get("Content-Type"); + if (format === "text/csv") { + response.text().then((text) => { + this.csv = text; + this.showTable(); + }); + } + }, + + /** + * Downloads the DataONE object + * @returns {Promise} Promise that resolves with the Response from DataONE + */ + downloadObject() { + return this.model.fetchDataObjectWithCredentials(); + }, + + /** Shows the CSV file as a table */ + showTable() { + this.table = new TableEditorView({ + viewMode: true, + csv: this.csv, + }); + this.el.innerHTML = ""; + this.el.appendChild(this.table.render().el); + }, + }, + ); + + return DataObjectView; +}); diff --git a/src/js/views/TableEditorView.js b/src/js/views/TableEditorView.js index 17924cf1a..aa80e2526 100644 --- a/src/js/views/TableEditorView.js +++ b/src/js/views/TableEditorView.js @@ -125,9 +125,7 @@ define([ }); }, - /** - * Renders the tableEditor - add UI for creating and editing tables - */ + /** @inheritdoc */ render() { // Insert the template into the view this.$el @@ -150,6 +148,7 @@ define([ // defaults to empty table this.createSpreadsheet(); } + return this; }, /** diff --git a/test/js/specs/unit/models/SolrResult.spec.js b/test/js/specs/unit/models/SolrResult.spec.js index b67f00a03..f3c08968b 100644 --- a/test/js/specs/unit/models/SolrResult.spec.js +++ b/test/js/specs/unit/models/SolrResult.spec.js @@ -1,20 +1,21 @@ -define(["../../../../../../../../src/js/models/SolrResult"], function ( - SolrResult, -) { +define(["models/SolrResult"], function (SolrResult) { // Configure the Chai assertion library var should = chai.should(); var expect = chai.expect; - let solrResult; + let solrResult, fetchStub; - describe("Search Test Suite", function () { + describe("SolrResult Test Suite", function () { /* Set up */ beforeEach(function () { solrResult = new SolrResult(); + // Create a stub for the fetch API + fetchStub = sinon.stub(window, "fetch"); }); /* Tear down */ - after(function () { + afterEach(function () { solrResult = undefined; + fetchStub.restore(); }); describe("The SolrResult model", function () { @@ -22,5 +23,98 @@ define(["../../../../../../../../src/js/models/SolrResult"], function ( solrResult.should.be.instanceof(SolrResult); }); }); + + describe("downloadWithCredentials", function () { + it("should download a file with valid credentials", async function () { + const mockBlob = new Blob(["test"], { type: "text/plain" }); + const mockResponse = new Response(mockBlob, { + status: 200, + headers: { + "Content-Disposition": 'attachment; filename="testfile.txt"', + }, + }); + + // Mock fetch response + fetchStub.resolves(mockResponse); + + // Spy on model.trigger to check if the events are triggered + const triggerSpy = sinon.spy(solrResult, "trigger"); + + // Execute the downloadWithCredentials method + await solrResult.downloadWithCredentials(); + + // Ensure that fetch was called once + sinon.assert.calledOnce(fetchStub); + + // Check if the downloadComplete event was triggered + sinon.assert.calledWith(triggerSpy, "downloadComplete"); + }); + }); + + describe("fetchDataObjectWithCredentials", function () { + it("should fetch the data object with valid credentials", async function () { + const mockResponse = new Response("{}", { status: 200 }); + + // Mock fetch response + fetchStub.resolves(mockResponse); + + // Execute the fetchDataObjectWithCredentials method + const response = await solrResult.fetchDataObjectWithCredentials(); + + // Ensure that fetch was called once + sinon.assert.calledOnce(fetchStub); + + // The response should be the mock response + response.status.should.equal(200); + }); + + it("should throw an error for a failed fetch", async function () { + // Mock a failed fetch response + fetchStub.rejects(new Error("Failed to fetch")); + + try { + await solrResult.fetchDataObjectWithCredentials(); + } catch (error) { + error.message.should.equal("Failed to fetch"); + } + + // Ensure that fetch was called once + sinon.assert.calledOnce(fetchStub); + }); + }); + + describe("getFileNameFromResponse", function () { + it("should extract filename from Content-Disposition header", function () { + const mockResponse = new Response(null, { + headers: { + "Content-Disposition": 'attachment; filename="testfile.txt"', + }, + }); + + // Execute getFileNameFromResponse + const filename = solrResult.getFileNameFromResponse(mockResponse); + + // Ensure the filename is correct + filename.should.equal("testfile.txt"); + }); + + it("should fall back to model attributes for filename", function () { + // Set model properties + sinon.stub(solrResult, "get").callsFake(function (attr) { + if (attr === "fileName") return "defaultFileName.txt"; + return null; + }); + + const mockResponse = new Response(null, { + headers: {}, + }); + + // Execute getFileNameFromResponse + const filename = solrResult.getFileNameFromResponse(mockResponse); + + // Ensure the fallback filename is correct + filename.should.equal("defaultFileName.txt"); + }); + }); }); });