Skip to content

Commit

Permalink
Add a download button to the data object view
Browse files Browse the repository at this point in the history
- Avoids inflating the download metrics, because the data has already been fetched. The download button simply triggers the download of the data file to the user's computer.
- Separate out logic for downloading fetched blob in SolrResult so it can be used independently of the DataObjectView
  • Loading branch information
robyngit committed Sep 12, 2024
1 parent 9088638 commit d9f82b6
Show file tree
Hide file tree
Showing 4 changed files with 110 additions and 33 deletions.
6 changes: 6 additions & 0 deletions src/css/metacatui-common.css
Original file line number Diff line number Diff line change
Expand Up @@ -9665,4 +9665,10 @@ body > #extension-is-installed {
> .alert-container {
margin: 3rem;
}
> .btn.download {
grid-row: 2;
justify-self: right;
margin-right: 1rem;
margin-top: 1rem;
}
}
79 changes: 52 additions & 27 deletions src/js/models/SolrResult.js
Original file line number Diff line number Diff line change
Expand Up @@ -283,34 +283,10 @@ define(["jquery", "underscore", "backbone"], ($, _, Backbone) => {
* request.
*/
async downloadWithCredentials() {
const model = this;

// Call the new getBlob method and handle the response
const response = await this.fetchDataObjectWithCredentials();
const blob = await response.blob();
const filename = this.getFileNameFromResponse(response);

// 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 this event
model.trigger("downloadComplete");
MetacatUI.analytics?.trackEvent(
"download",
"Download DataONEObject",
model.get("id"),
);
this.fetchDataObjectWithCredentials()
.then((response) => this.downloadFromResposne(response))
.catch((error) => this.handleDownloadError(error));
},

/**
Expand Down Expand Up @@ -370,6 +346,55 @@ define(["jquery", "underscore", "backbone"], ($, _, Backbone) => {
return filename;
},

/**
* Download data onto the user's computer from the response object
* @param {Response} response - The response object from the fetch request
* @since 0.0.0
*/
async downloadFromResposne(response) {
const model = this;
const blob = await response.blob();
const filename = this.getFileNameFromResponse(response);

// 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");
const url = URL.createObjectURL(blob);
a.href = url;
a.download = filename;
a.click();
a.remove();
URL.revokeObjectURL(url);
}

// Track this event
model.trigger("downloadComplete");
MetacatUI.analytics?.trackEvent(
"download",
"Download DataONEObject",
model.get("id"),
);
},

/**
* Handle an error that occurs when downloading the object
* @param {Error} e - The error that occurred
* @since 0.0.0
*/
handleDownloadError(e) {
const model = this;
model.trigger("downloadError");
// Track the error
MetacatUI.analytics?.trackException(
`Download DataONEObject error: ${e || ""}`,
model.get("id"),
true,
);
},

getInfo: function (fields) {
var model = this;

Expand Down
46 changes: 43 additions & 3 deletions src/js/views/DataObjectView.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ define([
const CLASS_NAMES = {
base: BASE_CLASS,
well: "well", // Bootstrap class
downloadButton: ["btn", "download"],
downloadIcon: ["icon", "icon-cloud-download"],
};

// User-facing text
const DOWNLOAD_BUTTON_TEXT = "Download";
const LOADING_MESSAGE = "Loading data...";
const ERROR_TITLE = "Uh oh 😕";
const ERROR_MESSAGE =
Expand Down Expand Up @@ -59,9 +62,12 @@ define([
* Initializes the DataObjectView
* @param {object} options - Options for the view
* @param {SolrResult} options.model - A SolrResult model
* @param {Element} [options.buttonContainer] - The container for the
* download button (defaults to the view's element)
*/
initialize(options) {
this.model = options.model;
this.buttonContainer = options.buttonContainer || this.el;
// 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") ||
Expand All @@ -73,7 +79,7 @@ define([
this.$el.empty();
this.showLoading();
this.downloadObject()
.then((response) => this.renderObject(response))
.then((response) => this.handleResponse(response))
.catch((error) => this.showError(error?.message || error));
return this;
},
Expand Down Expand Up @@ -113,6 +119,25 @@ define([
);
},

/**
* Handle the response from the DataONE object API. Renders the data and
* shows the download button if the response is successful.
* @param {Response} response - The response from the DataONE object API
*/
handleResponse(response) {
if (response.ok) {
this.hideLoading();
this.$el.html("");
// Response can only be consumed once (e.g. to text), so keep a copy
// to convert to a blob for downloading if user requests it.
this.response = response.clone();
this.renderObject(response);
this.renderDownloadButton();
} else {
this.showError(response.statusText);
}
},

/**
* With the already fetched DataONE object, check the format and render
* the object accordingly.
Expand All @@ -121,7 +146,6 @@ define([
renderObject(response) {
try {
this.hideLoading();
this.response = response;
const format = response.headers.get("Content-Type");
if (format === "text/csv") {
response.text().then((text) => {
Expand All @@ -134,6 +158,23 @@ define([
}
},

/** Renders a download button */
renderDownloadButton() {
const view = this;
const downloadButton = document.createElement("a");
downloadButton.textContent = DOWNLOAD_BUTTON_TEXT;
downloadButton.classList.add(...CLASS_NAMES.downloadButton);
const icon = document.createElement("i");
icon.classList.add(...CLASS_NAMES.downloadIcon);
downloadButton.appendChild(icon);
downloadButton.onclick = (e) => {
e.preventDefault();
const response = view.response.clone();
view.model.downloadFromResposne(response);
};
this.buttonContainer.appendChild(downloadButton);
},

/**
* Downloads the DataONE object
* @returns {Promise} Promise that resolves with the Response from DataONE
Expand All @@ -148,7 +189,6 @@ define([
viewMode: true,
csv: this.csv,
});
this.el.innerHTML = "";
this.el.appendChild(this.table.render().el);
},
},
Expand Down
12 changes: 9 additions & 3 deletions src/js/views/ViewObjectButtonView.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ define(["jquery", "backbone", "views/DataObjectView"], (
// The base class for the view
const BASE_CLASS = "view-data-button";
const CLASS_NAMES = {
button: [BASE_CLASS, "btn"],
base: [BASE_CLASS, "btn"],
button: ["btn"],
icon: ["icon", "icon-eye-open"],
modal: ["modal", "hide", "fade", "full-screen"],
header: ["modal-header"],
Expand All @@ -17,6 +18,7 @@ define(["jquery", "backbone", "views/DataObjectView"], (
footer: ["modal-footer"],
};
const BUTTON_TEXT = "View";
const CLOSE_BUTTON_TEXT = "Close";

/**
* @class ViewDataButtonView
Expand All @@ -35,7 +37,7 @@ define(["jquery", "backbone", "views/DataObjectView"], (
type: "ViewDataButtonView",

/** @inheritdoc */
className: CLASS_NAMES.button.join(" "),
className: CLASS_NAMES.base.join(" "),

/** @inheritdoc */
tagName: "a",
Expand All @@ -62,7 +64,9 @@ define(["jquery", "backbone", "views/DataObjectView"], (
<div class="${CLASS_NAMES.body.join(" ")}">
<p>loading...</p>
</div>
<div class="${CLASS_NAMES.footer.join(" ")}"></div>
<div class="${CLASS_NAMES.footer.join(" ")}">
<button class="${CLASS_NAMES.button.join(" ")}" data-dismiss="modal" aria-hidden="true">${CLOSE_BUTTON_TEXT}</button>
</div>
</div>`;
},

Expand Down Expand Up @@ -94,8 +98,10 @@ define(["jquery", "backbone", "views/DataObjectView"], (
});
const modal = $(modalHTML).modal();
const modalBody = modal.find(`.${CLASS_NAMES.body.join(".")}`);
const modalFooter = modal.find(`.${CLASS_NAMES.footer.join(".")}`)[0];
const objectView = new DataObjectView({
model: this.model,
buttonContainer: modalFooter
});
modalBody.empty();
modalBody.append(objectView.render().el);
Expand Down

0 comments on commit d9f82b6

Please sign in to comment.