From 9088638331356072b13daeb0512a0e94cafd180f Mon Sep 17 00:00:00 2001 From: robyngit Date: Thu, 12 Sep 2024 16:37:36 -0400 Subject: [PATCH 01/10] Display loading & error msgs in data object view Issue #1758 --- src/css/metacatui-common.css | 14 +++-- src/js/views/DataObjectView.js | 93 ++++++++++++++++++++++++++++++---- 2 files changed, 94 insertions(+), 13 deletions(-) diff --git a/src/css/metacatui-common.css b/src/css/metacatui-common.css index 3a3899ca5..6ff84ee2e 100644 --- a/src/css/metacatui-common.css +++ b/src/css/metacatui-common.css @@ -2625,7 +2625,7 @@ section#user-citations { display: flex; flex-wrap: wrap; - >a:not(:first-child) { + > a:not(:first-child) { margin-left: 0.7rem; } } @@ -9651,10 +9651,18 @@ body > #extension-is-installed { } } - /****************************************** ** Data Object View ** ******************************************/ .object-view { height: 100%; -} \ No newline at end of file + display: grid; + align-items: center; + > .notification { + font-size: 1.6rem; + margin: 3rem; + } + > .alert-container { + margin: 3rem; + } +} diff --git a/src/js/views/DataObjectView.js b/src/js/views/DataObjectView.js index 569d0cb6d..58a5f60cb 100644 --- a/src/js/views/DataObjectView.js +++ b/src/js/views/DataObjectView.js @@ -1,8 +1,25 @@ "use strict"; -define(["backbone", "views/TableEditorView"], (Backbone, TableEditorView) => { - // The base class for the view +define([ + "underscore", + "backbone", + "views/TableEditorView", + "text!templates/loading.html", + "text!templates/alert.html", +], (_, Backbone, TableEditorView, LoadingTemplate, AlertTemplate) => { + // The classes used by this view const BASE_CLASS = "object-view"; + const CLASS_NAMES = { + base: BASE_CLASS, + well: "well", // Bootstrap class + }; + + // User-facing text + const LOADING_MESSAGE = "Loading data..."; + const ERROR_TITLE = "Uh oh 😕"; + const ERROR_MESSAGE = + "There was an error displaying the object. Please try again later or send us an email."; + const MORE_DETAILS_PREFIX = "More details: "; /** * @class DataObjectView @@ -21,11 +38,23 @@ define(["backbone", "views/TableEditorView"], (Backbone, TableEditorView) => { type: "DataObjectView", /** @inheritdoc */ - className: BASE_CLASS, + className: CLASS_NAMES.base, /** @inheritdoc */ tagName: "div", + /** + * The template for the loading spinner + * @type {UnderscoreTemplate} + */ + loadingTemplate: _.template(LoadingTemplate), + + /** + * The template for the alert message + * @type {UnderscoreTemplate} + */ + alertTemplate: _.template(AlertTemplate), + /** * Initializes the DataObjectView * @param {object} options - Options for the view @@ -42,22 +71,66 @@ define(["backbone", "views/TableEditorView"], (Backbone, TableEditorView) => { /** @inheritdoc */ render() { this.$el.empty(); - this.downloadObject().then((response) => this.renderObject(response)); + this.showLoading(); + this.downloadObject() + .then((response) => this.renderObject(response)) + .catch((error) => this.showError(error?.message || error)); return this; }, + /** Indicate that the data is loading */ + showLoading() { + this.$el.html( + this.loadingTemplate({ + msg: LOADING_MESSAGE, + }), + ); + this.el.classList.add(CLASS_NAMES.well); + }, + + /** Remove the loading spinner */ + hideLoading() { + this.el.classList.remove(CLASS_NAMES.well); + }, + + /** + * Display an error message to the user + * @param {string} message - The error message to display + */ + showError(message) { + this.hideLoading(); + const alertTitle = `

${ERROR_TITLE}

`; + let alertMessage = alertTitle + ERROR_MESSAGE; + if (message) { + alertMessage += `

${MORE_DETAILS_PREFIX}${message}`; + } + this.$el.html( + this.alertTemplate({ + includeEmail: true, + msg: alertMessage, + remove: false, + }), + ); + }, + /** * 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(); - }); + try { + this.hideLoading(); + this.response = response; + const format = response.headers.get("Content-Type"); + if (format === "text/csv") { + response.text().then((text) => { + this.csv = text; + this.showTable(); + }); + } + } catch (error) { + this.showError(error?.message || error); } }, From d9f82b6e4af654490c2cef68af9689c47fbe40dc Mon Sep 17 00:00:00 2001 From: robyngit Date: Thu, 12 Sep 2024 17:43:58 -0400 Subject: [PATCH 02/10] Add a download button to the data object view - 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 --- src/css/metacatui-common.css | 6 +++ src/js/models/SolrResult.js | 79 ++++++++++++++++++---------- src/js/views/DataObjectView.js | 46 ++++++++++++++-- src/js/views/ViewObjectButtonView.js | 12 +++-- 4 files changed, 110 insertions(+), 33 deletions(-) diff --git a/src/css/metacatui-common.css b/src/css/metacatui-common.css index 6ff84ee2e..9c45663d8 100644 --- a/src/css/metacatui-common.css +++ b/src/css/metacatui-common.css @@ -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; + } } diff --git a/src/js/models/SolrResult.js b/src/js/models/SolrResult.js index fd8c2ddeb..25cc0b046 100644 --- a/src/js/models/SolrResult.js +++ b/src/js/models/SolrResult.js @@ -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)); }, /** @@ -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; diff --git a/src/js/views/DataObjectView.js b/src/js/views/DataObjectView.js index 58a5f60cb..0a832fe91 100644 --- a/src/js/views/DataObjectView.js +++ b/src/js/views/DataObjectView.js @@ -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 = @@ -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") || @@ -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; }, @@ -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. @@ -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) => { @@ -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 @@ -148,7 +189,6 @@ define([ viewMode: true, csv: this.csv, }); - this.el.innerHTML = ""; this.el.appendChild(this.table.render().el); }, }, diff --git a/src/js/views/ViewObjectButtonView.js b/src/js/views/ViewObjectButtonView.js index 167208a52..08ba59dda 100644 --- a/src/js/views/ViewObjectButtonView.js +++ b/src/js/views/ViewObjectButtonView.js @@ -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"], @@ -17,6 +18,7 @@ define(["jquery", "backbone", "views/DataObjectView"], ( footer: ["modal-footer"], }; const BUTTON_TEXT = "View"; + const CLOSE_BUTTON_TEXT = "Close"; /** * @class ViewDataButtonView @@ -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", @@ -62,7 +64,9 @@ define(["jquery", "backbone", "views/DataObjectView"], (

loading...

-
+
+ +
`; }, @@ -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); From 444521232f5edf2b3c144917a0183c8015427b62 Mon Sep 17 00:00:00 2001 From: robyngit Date: Wed, 18 Sep 2024 14:19:55 -0400 Subject: [PATCH 03/10] Speed up rendering large tables in TableEditorView - Instead of creating an empty table, then populating it, we now add table cell content as it's created - Instead of rendering the table in editing mode then removing editing elements if the table is in view-mode, we now render the table without editing elements if the table is in view-mode - Also use document fragment to append table rows to the table element, which is faster than appending each row individually Issue #1758 --- src/js/templates/tableEditor.html | 3 + src/js/views/TableEditorView.js | 197 ++++++++++++++++++++++-------- 2 files changed, 147 insertions(+), 53 deletions(-) diff --git a/src/js/templates/tableEditor.html b/src/js/templates/tableEditor.html index c514d303d..45db27912 100644 --- a/src/js/templates/tableEditor.html +++ b/src/js/templates/tableEditor.html @@ -22,12 +22,15 @@ + + <% if (!viewMode) { %>
+ <% } %>