diff --git a/src/css/metacatui-common.css b/src/css/metacatui-common.css index 9e8676f00..3b4faad27 100644 --- a/src/css/metacatui-common.css +++ b/src/css/metacatui-common.css @@ -7937,7 +7937,8 @@ ul.side-nav-items { } .data-package-item td.error > [contenteditable] { - border: 2px solid red; + border: 1.5px solid red; + border-radius: 6px; } /* Editor package table column 1 */ .data-package-item td:first-of-type { diff --git a/src/js/collections/DataPackage.js b/src/js/collections/DataPackage.js index e7f2acf7f..ce5e8c874 100644 --- a/src/js/collections/DataPackage.js +++ b/src/js/collections/DataPackage.js @@ -433,6 +433,43 @@ define([ } }, + /** + * Fetches the system metadata for all member models that are marked as + * placeholder documents (i.e., isPlaceHolder_b === true). This property + * is retrieved from Solr and indicates that the file ID is referenced in + * a resource map, but has either not yet been indexed or is missing from + * the repository. + * @returns {Promise} A promise that resolves when all placeholder member + * models have been fetched. + * @since 0.0.0 + */ + async fetchSysMetaForPlaceholders() { + const placeholder_prop = "isPlaceHolder_b"; + const placeholder_models = this.filter( + (model) => model.get(placeholder_prop) === true, + ); + await this.fetchMemberModels(placeholder_models); + }, + + /** + * Checks whether any member models are missing files + * @returns {boolean} True if any member models are missing files + * @since 0.0.0 + */ + missingFilesDetected() { + return this.some((model) => model.fileDoesNotExist()); + }, + + /** + * Returns an array of member models that are missing files + * @returns {Backbone.Model[]} An array of member models that are missing + * files + * @since 0.0.0 + */ + getMissingFileModels() { + return this.filter((model) => model.fileDoesNotExist()); + }, + /** * Fetches member models in batches to avoid fetching all members * simultaneously. @@ -555,8 +592,13 @@ define([ // Wait for whichever finishes first return await Promise.race([fetchPromise, timerPromise]); } catch (err) { - // Retry if we still have attempts left - if (attempt >= maxRetries - 1) { + // Retry if we still have attempts left and the type of error makes + // sense to retry + const dontRetryErrors = [401, 403, 404, 410]; + if ( + attempt >= maxRetries - 1 || + dontRetryErrors.includes(memberModel.get("errorStatus")) + ) { throw err; } // Recursively call ourselves with an incremented attempt count @@ -681,8 +723,12 @@ define([ resolve(model); }); }, - error: (m, response) => { - reject(new Error(response?.statusText || "Model fetch failed")); + error: (_m, response) => { + model.set("errorStatus", response.status); + model.set("errorMessage", response.statusText); + reject(new Error(response.statusText || "Fetch failed"), { + cause: response, + }); }, }); }); diff --git a/src/js/common/QueryService.js b/src/js/common/QueryService.js index 2748cf311..aaf7164d6 100644 --- a/src/js/common/QueryService.js +++ b/src/js/common/QueryService.js @@ -24,7 +24,9 @@ define(["jquery"], ($) => { * URL. * @property {boolean} [usePost] Force POST / GET (overrides auto-choice). * @property {boolean} [useAuth=true] Inject MetacatUI auth headers? - * @property {boolean} [archived] Include archived items? Default `false`. + * @property {boolean} [archived] Include archived resources in the results. A + * special filter query is applied to include all docs that have any value for + * the `archived` field. * @property {boolean} [group] Use Solr grouping (group=true)? * @property {string} [groupField] Field to group by (if `group` is true). * @property {number} [groupLimit] Limit of groups to return (if `group` is @@ -483,7 +485,6 @@ define(["jquery"], ($) => { }); } - // TODO - are there other values possible for the archived param? if (archived) { params["archived"] = "archived:*"; } diff --git a/src/js/models/DataONEObject.js b/src/js/models/DataONEObject.js index 25abf559d..3c12951f7 100644 --- a/src/js/models/DataONEObject.js +++ b/src/js/models/DataONEObject.js @@ -392,7 +392,10 @@ define([ */ parse(response) { // If the response is XML - if (typeof response === "string" && response.indexOf("<") == 0) { + if ( + typeof response === "string" && + response.trim().indexOf("<") === 0 + ) { const responseDoc = $.parseHTML(response); let systemMetadata; @@ -1890,6 +1893,17 @@ define([ ); }, + /** + * Checks for a 404 error in the error status or message + * @returns {boolean} True if a 404 error is found + * @since 0.0.0 + */ + fileDoesNotExist() { + if (this.get("errorStatus") === 404) return true; + if (this.get("errorMessage")?.includes("404")) return true; + return false; + }, + /** * A utility function that will format an XML string or XML nodes by camel-casing the node names, as necessary * @param {string|Element} xml - The XML to format diff --git a/src/js/models/PackageModel.js b/src/js/models/PackageModel.js index 12e066e0d..5ee096325 100644 --- a/src/js/models/PackageModel.js +++ b/src/js/models/PackageModel.js @@ -196,7 +196,7 @@ define([ fields: `resourceMap,fileName,obsoletes,obsoletedBy,size,formatType,formatId,` + `id,datasource,rightsHolder,dateUploaded,archived,title,origin,` + - `prov_instanceOfClass,isDocumentedBy,isPublic`, + `prov_instanceOfClass,isDocumentedBy,isPublic,isPlaceHolder_b`, rows: 1000, archived: this.get("getArchivedMembers") ? true : false, }) @@ -235,7 +235,7 @@ define([ return this; }, - /* + /** * Send custom options to the Backbone.Model.fetch() function */ fetch(options = {}) { @@ -291,7 +291,7 @@ define([ }); }, - /* + /** * Deserialize a Package from OAI-ORE RDF XML */ parse(response) { diff --git a/src/js/views/DataItemView.js b/src/js/views/DataItemView.js index d28d44a89..5bb890f07 100644 --- a/src/js/views/DataItemView.js +++ b/src/js/views/DataItemView.js @@ -114,6 +114,14 @@ define([ render() { // Prevent duplicate listeners this.stopListening(); + // listen for changes to rerender the view + this.listenTo( + this.model, + "change:fileName change:title change:id change:formatType " + + "change:formatId change:type change:resourceMap change:documents change:isDocumentedBy " + + "change:size change:nodeLevel change:uploadStatus change:errorMessage change:errorStatus", + this.render, + ); let itemPathParts = []; @@ -483,15 +491,6 @@ define([ this.toggleSaving, ); - // listen for changes to rerender the view - this.listenTo( - this.model, - "change:fileName change:title change:id change:formatType " + - "change:formatId change:type change:resourceMap change:documents change:isDocumentedBy " + - "change:size change:nodeLevel change:uploadStatus change:errorMessage", - this.render, - ); // render changes to the item - const view = this; this.listenTo(this.model, "replace", (newModel) => { view.model = newModel; @@ -642,6 +641,10 @@ define([ .attr("data-trigger", "hover") .attr("data-delay", "300") .attr("data-title", objectTitleTooltip); + + // Check for errors indicating that the file does not exist and + // show im UI + this.handleNonExistentFile(); } } @@ -1462,13 +1465,18 @@ define([ * @since 2.32.1 */ showError(message) { + let messageNormalized = message; + if (messageNormalized === "404") { + messageNormalized = + "This file does not exist in the repository. Please remove or replace it."; + } this.$el.removeClass("loading"); const nameColumn = this.$(".name"); nameColumn.addClass("error"); // Append an error message this.errorEl = $(document.createElement("div")) .addClass("error-message") - .text(`There was an error: ${message}`); + .text(`Error: ${messageNormalized}`); nameColumn.append(this.errorEl); const icon = this.$(".type-icon"); icon.addClass("error"); @@ -1578,8 +1586,22 @@ define([ return null; }, + /** + * Handle the case where the file does not exist on the server + * @since 0.0.0 + */ + handleNonExistentFile() { + if (!this.model.fileDoesNotExist()) return; + this.$el.addClass("non-existent-file"); + if (this.downloadButtonView) { + this.downloadButtonView.inactivate( + "This file does not exist on the server, but is referenced in the metadata. Please contact the author for assistance.", + ); + } + }, + downloadFile(e) { - this.downloadButtonView.download(e); + this.downloadButtonView?.download(e); }, // Member row metrics for the package table diff --git a/src/js/views/DataPackageView.js b/src/js/views/DataPackageView.js index e961a4e0b..7e0288e1a 100644 --- a/src/js/views/DataPackageView.js +++ b/src/js/views/DataPackageView.js @@ -1,4 +1,4 @@ -"use strict"; +"use strict"; define([ "jquery", @@ -161,8 +161,8 @@ define([ if (this.edit) { // Listen for add events because models are being merged - this.listenTo(this.dataPackage, "add", this.addOne); - this.listenTo(this.dataPackage, "fileAdded", this.addOne); + this.stopListening(this.dataPackage, "add fileAdded", this.addOne); + this.listenTo(this.dataPackage, "add fileAdded", this.addOne); } // Render the current set of models in the DataPackage @@ -204,6 +204,7 @@ define([ // Don't add duplicate rows if (this.$(`.data-package-item[data-id='${item.id}']`).length) return; + if (_.contains(Object.keys(this.subviews), item.id)) return; // Don't add data package if ( @@ -217,10 +218,6 @@ define([ let parentRow; let delayedModels; - if (_.contains(Object.keys(this.subviews), item.id)) { - return; // Don't double render - } - let itemPath = null; const view = this; if (!_.isEmpty(this.atLocationObj)) { @@ -503,10 +500,7 @@ define([ this.sortedFilePathObj = sortedFilePathObj; this.addFilesAndFolders(sortedFilePathObj); - } else { - this.dataPackage.each(this.addOne, this, this.dataPackage); } - this.dataPackage.each(this.addOne, this, this.dataPackage); } else { this.dataPackage.each(this.addOne, this); diff --git a/src/js/views/DownloadButtonView.js b/src/js/views/DownloadButtonView.js index 161addbc3..87aa7039a 100644 --- a/src/js/views/DownloadButtonView.js +++ b/src/js/views/DownloadButtonView.js @@ -112,17 +112,9 @@ define([ // then we can assume the resource map object is private. So disable // the download button. if (!this.model.get("indexDoc")) { - this.$el - .attr("disabled", "disabled") - .addClass("disabled") - .attr("href", "") - .tooltip({ - trigger: "hover", - placement: "top", - delay: 500, - title: - "This dataset may contain private data, so each data file should be downloaded individually.", - }); + this.inactivate( + "This dataset may contain private data, so each data file should be downloaded individually.", + ); } } // For individual DataONEObjects @@ -142,27 +134,34 @@ define([ this.model.type === "Package" && this.model.getTotalSize() > MetacatUI.appModel.get("maxDownloadSize") ) { - this.$el - .addClass("tooltip-this") - .attr("disabled", "disabled") - .attr( - "data-title", - "This dataset is too large to download as a package. Please download the files individually or contact us for alternate data access.", - ) - .attr("data-placement", "top") - .attr("data-trigger", "hover") - .attr("data-container", "body"); - - // Removing the `href` attribute while disabling the download button. - this.$el.removeAttr("href"); - - // Removing pointer as cursor and setting to default - this.$el.css("cursor", "default"); + this.inactivate( + `This dataset is too large to download all at once. Please download individual files separately."`, + ); } return this; }, + /** + * Prevents the download button from being clickable and adds a tooltip + * with a message explaining why. + * @param {string} [message] - The message to display in the tooltip. + * @since 0.0.0 + */ + inactivate(message = "This file is not available for download.") { + this.$el.addClass("disabled").attr("disabled", "disabled"); + this.$el.css("cursor", "default"); + this.$el.removeAttr("href"); + + this.$el.tooltip({ + trigger: "hover", + placement: "top", + delay: 100, + title: message, + container: "body", + }); + }, + /** * Handles the download event when the button is clicked. Checks for * conditions like authentication, public/private access, and download size diff --git a/src/js/views/MetadataView.js b/src/js/views/MetadataView.js index 0734b10f7..565aa0ff6 100644 --- a/src/js/views/MetadataView.js +++ b/src/js/views/MetadataView.js @@ -257,6 +257,9 @@ define([ dataPackageView.dataPackageCollection = this.dataPackage; dataPackageView.checkForPrivateMembers(); } + // Check if any docs exist as placeholders only, and fetch sysmeta + // instead of relying on Solr + this.dataPackage.fetchSysMetaForPlaceholders(); }); this.listenToOnce(this.dataPackage, "fetchFailed", () => { @@ -567,6 +570,11 @@ define([ viewRef.trigger("renderComplete"); } } catch (e) { + // eslint-disable-next-line no-console + console.warn( + "Rendering from index because of an error rendering from view service:", + e, + ); MetacatUI.analytics?.trackException( `Error rendering metadata from the view service. Fellback to index, ${e}, Response: ${response}`, pid, @@ -1172,6 +1180,8 @@ define([ metricsModel: this.metricsModel, }); + // TODO: move all the following behaviour into the DataPackageView + // Get the package table container const tablesContainer = this.$(this.tableContainer); @@ -1220,6 +1230,45 @@ define([ // Trigger a custom event in this view that indicates the package table // has been rendered this.trigger("dataPackageRendered"); + + // Listen for and handle any missing data objects in the package + this.handleMissingFiles(); + }, + + /** + * If there files listed in the dataPackage don't exist in the repository, + * inactivate the download buttons for those files in the entity tables + * and inactivates the download all button in the package table. + * @since 0.0.0 + */ + handleMissingFiles() { + this.stopListening( + this.dataPackage, + "change:errorMessage", + this.handleMissingFiles, + ); + this.listenTo( + this.dataPackage, + "change:errorMessage", + this.handleMissingFiles, + ); + if (!this.dataPackage.missingFilesDetected()) return; + if (!this.downloadButtonView) return; + this.downloadButtonView.inactivate( + "Some files are missing from this package, so downloading the entire package is not available. Please download individual files instead.", + ); + + const missingModels = this.dataPackage.getMissingFileModels(); + const ids = missingModels?.map((m) => m.get("id")); + // Inactivate the view & download buttons in the entity tables + ids.forEach((id) => { + const entityButton = this.entityDownloadButtons[id]; + if (entityButton) { + entityButton.inactivate( + "This file is missing from the package and cannot be downloaded.", + ); + } + }); }, /** @@ -2652,12 +2701,18 @@ define([ "control-group", "data-interaction-buttons", ); + const id = solrResult.get("id"); const nameLabel = $(container).find("label:contains('Entity Name')"); + // Create a button to download the data object const downloadButton = new DownloadButtonView({ model: solrResult, }); + if (!this.entityDownloadButtons) this.entityDownloadButtons = {}; + this.entityDownloadButtons[id] = downloadButton; downloadButton.render(); + + // Create a button to view the data object const viewButton = new ViewObjectButtonView({ model: solrResult, modalContainer: this.$el,