From f0919678d3a6775bf16feef135a6621f65e20313 Mon Sep 17 00:00:00 2001 From: Val Hendrix Date: Fri, 13 Dec 2024 13:19:14 -0800 Subject: [PATCH 1/6] bug: Fixes loading large number data files in Metadata Editor Enables Batch fetch of member models and updates save button control toggling - Adds `fetchMemberModels` method to `DataPackage` to fetch member models in batches. - Updates `fetch` method in `DataPackage` to use `fetchMemberModels`. - Adds listener for `numLoadingFileMetadata` change in `EML211EditorView`. - Updates `toggleEnableControls` in `EML211EditorView` to handle `numLoadingFileMetadata`. - Adds `fetchBatchSize` configuration to `AppModel` to control batch size for fetching member models. Closes nceas/metacatui#2547 --- src/js/collections/DataPackage.js | 163 +++++++++++++++------- src/js/models/AppModel.js | 16 +++ src/js/views/metadata/EML211EditorView.js | 25 +++- 3 files changed, 148 insertions(+), 56 deletions(-) diff --git a/src/js/collections/DataPackage.js b/src/js/collections/DataPackage.js index a51ffa671..9ff793ef2 100644 --- a/src/js/collections/DataPackage.js +++ b/src/js/collections/DataPackage.js @@ -433,6 +433,116 @@ define([ } }, + + /** + * Fetches member models in batches to avoid fetching all members simultaneously. + * + * @param {Array} models - The array of member models to fetch. + * @param {number} [index=0] - The current index of the model being fetched. + * @param {number} [batchSize=10] - The number of models to fetch in each batch. + * @param {number} [timeout=5000] - The timeout for each fetch request in milliseconds. + * @param {number} [maxRetries=3] - The maximum number of retries for each fetch request. + */ + fetchMemberModels(models, index = 0, batchSize = 10, timeout = 5000, maxRetries = 3) { + // Update the number of file metadata items being loaded + this.packageModel.set("numLoadingFileMetadata", models.length - index); + + // If the index is greater than or equal to the length of the models array, stop fetching + if (index >= models.length) { + this.triggerComplete(); + return; + } + + // If batchSize is 0, set it to the total number of models + if (batchSize == 0) batchSize = models.length; + + const collection = this; + // Slice the models array to get the current batch + const batch = models.slice(index, index + batchSize); + + // Create an array of promises for fetching each model in the batch + const fetchPromises = batch.map((memberModel) => { + return new Promise((resolve, reject) => { + const attemptFetch = (retriesLeft) => { + // Create a promise for the fetch request + const fetchPromise = new Promise((fetchResolve, fetchReject) => { + memberModel.fetch({ + success: () => { + // Once the model is synced, handle the response + memberModel.once("sync", (oldModel) => { + const newModel = collection.getMember(oldModel); + + // If the type of the old model is different from the new model + if (oldModel.type != newModel.type) { + if (newModel.type == "DataPackage") { + // If the new model is a DataPackage, replace the old model with the new one + oldModel.trigger("replace", newModel); + fetchResolve(); + } else { + // Otherwise, fetch the new model and replace the old model with the new one + newModel.set("synced", false); + newModel.fetch(); + newModel.once("sync", (fetchedModel) => { + fetchedModel.set("synced", true); + collection.remove(oldModel); + collection.add(fetchedModel); + oldModel.trigger("replace", newModel); + if (newModel.type == "EML") collection.trigger("add:EML"); + fetchResolve(); + }); + } + } else { + // If the type of the old model is the same as the new model, merge the new model into the collection + newModel.set("synced", true); + collection.add(newModel, { merge: true }); + if (newModel.type == "EML") collection.trigger("add:EML"); + fetchResolve(); + } + }); + }, + error: (model, response) => fetchReject(new Error(response.statusText)) + }); + }); + + // Create a promise for the timeout + const timeoutPromise = new Promise((_, timeoutReject) => { + setTimeout(() => timeoutReject(new Error("Fetch timed out")), timeout); + }); + + // Race the fetch promise against the timeout promise + Promise.race([fetchPromise, timeoutPromise]) + .then(resolve) + .catch((error) => { + if (retriesLeft > 0) { + // Retry the fetch if there are retries left + console.warn(`Retrying fetch for model: ${memberModel.id}, retries left: ${retriesLeft}, error: ${error}`); + attemptFetch(retriesLeft - 1); + } else { + // Reject the promise if all retries are exhausted + console.error(`Failed to fetch model: ${memberModel.id} after ${maxRetries} retries, error: ${error}`); + reject(error); + } + }); + }; + + // Start the fetch attempt with the maximum number of retries + attemptFetch(maxRetries); + }); + }); + + // Once all fetch promises are resolved, fetch the next batch + Promise.allSettled(fetchPromises).then((results) => { + const errors = results.filter(result => result.status === "rejected"); + if (errors.length > 0) { + console.error("Error fetching member models:", errors); + } + // Fetch the next batch of models + this.fetchMemberModels.call(collection, models, index + batchSize, batchSize, timeout, maxRetries); + }).catch((error) => { + console.error("Error fetching member models:", error); + }); + }, + /** * Overload fetch calls for a DataPackage * @param {object} [options] - Optional options for this fetch that get @@ -492,6 +602,7 @@ define([ return Backbone.Collection.prototype.fetch .call(this, fetchOptions) .fail(() => + console.log("Fetch failed. Retrying with user login details..."), // If the initial fetch fails, retry with user login details retryFetch(), ); @@ -711,56 +822,9 @@ define([ // Don't fetch each member model if the fetchModels property on this // Collection is set to false if (this.fetchModels !== false) { - // Add the models to the collection now, silently this.add(models, - // {silent: true}); - - // Retrieve the model for each member - const collection = this; - models.forEach((model) => { - model.fetch(); - model.once("sync", (oldModel) => { - // Get the right model type based on the model values - const newModel = collection.getMember(oldModel); - - // If the model type has changed, then mark the model as - // unsynced, since there may be custom fetch() options for the - // new model - if (oldModel.type !== newModel.type) { - // DataPackages shouldn't be fetched until we support nested - // packages better in the UI - if (newModel.type === "DataPackage") { - // Trigger a replace event so other parts of the app know - // when a model has been replaced with a different type - oldModel.trigger("replace", newModel); - } else { - newModel.set("synced", false); - - newModel.fetch(); - newModel.once("sync", (fetchedModel) => { - fetchedModel.set("synced", true); + // Start fetching member models + this.fetchMemberModels.call(this, models, 0, MetacatUI.appModel.get("batchModeValue")); - // Remove the model from the collection and add it back - collection.remove(oldModel); - collection.add(fetchedModel); - - // Trigger a replace event so other parts of the app know - // when a model has been replaced with a different type - oldModel.trigger("replace", newModel); - - if (newModel.type === "EML") - collection.trigger("add:EML"); - }); - } - } else { - newModel.set("synced", true); - collection.add(newModel, { - merge: true, - }); - - if (newModel.type === "EML") collection.trigger("add:EML"); - } - }); - }); } } catch (error) { // TODO: Handle the error @@ -3703,6 +3767,7 @@ define([ this.packageModel.updateSysMeta(); }, + /** * Tracks the upload status of DataONEObject models in this collection. If * they are `loading` into the DOM or `in progress` of an upload to the diff --git a/src/js/models/AppModel.js b/src/js/models/AppModel.js index 72ee1825b..8bfb70265 100644 --- a/src/js/models/AppModel.js +++ b/src/js/models/AppModel.js @@ -2433,6 +2433,22 @@ define(["jquery", "underscore", "backbone"], function ($, _, Backbone) { * @example application%2Fbagit-097 */ packageFormat: "application%2Fbagit-1.0", + /** + * Whether to batch requests to the DataONE API. This is an experimental feature + * and should be used with caution. If set to a number greater than 0, MetacatUI will + * batch requests to the DataONE API and send them in groups of this size. This can + * improve performance when making many requests to the DataONE API, but can also + * cause issues if the requests are too large or if the DataONE API is not able to + * handle the batched requests. + * + * Currently, this feature is only used in the DataPackageModel when fetching the + * list of DataONE member models. + * + * @type {number} + * @default 0 + * @example 20 + */ + batchModeValue: 0, }, MetacatUI.AppConfig, ), diff --git a/src/js/views/metadata/EML211EditorView.js b/src/js/views/metadata/EML211EditorView.js index ada94708d..e2a940691 100644 --- a/src/js/views/metadata/EML211EditorView.js +++ b/src/js/views/metadata/EML211EditorView.js @@ -552,6 +552,12 @@ define([ "change:numLoadingFiles", this.toggleEnableControls, ); + + this.listenTo( + MetacatUI.rootDataPackage.packageModel, + "change:numLoadingFileMetadata", + this.toggleEnableControls, + ); }, /** Render the Data Package View and insert it into this view */ @@ -1191,15 +1197,20 @@ define([ */ toggleEnableControls() { if (MetacatUI.rootDataPackage.packageModel.get("isLoadingFiles")) { - const noun = - MetacatUI.rootDataPackage.packageModel.get("numLoadingFiles") > 1 - ? " files" - : " file"; + let noun = + MetacatUI.rootDataPackage.packageModel.get("numLoadingFiles") > 1 + ? " files" + : " file"; this.disableControls( - `Waiting for ${MetacatUI.rootDataPackage.packageModel.get( - "numLoadingFiles", - )}${noun} to upload...`, + "Waiting for " + + MetacatUI.rootDataPackage.packageModel.get("numLoadingFiles") + + noun + + " to upload...", ); + } else if (MetacatUI.rootDataPackage.packageModel.get("numLoadingFileMetadata") >0) { + this.disableControls("Waiting for " + + MetacatUI.rootDataPackage.packageModel.get("numLoadingFileMetadata") + + " file metadata to load..."); } else { this.enableControls(); } From 060b09a04e4522999f0b1948eb5b73b1e59752bf Mon Sep 17 00:00:00 2001 From: Val Hendrix Date: Fri, 13 Dec 2024 16:40:39 -0800 Subject: [PATCH 2/6] feat: implements batch file upload with promises - Updated `fetchBatchSize` in `DataPackage.js` to replace `batchModeValue`. - Added `uploadBatchSize` configuration in `AppModel.js`. - Implemented `uploadFilesInBatch` method in `DataItemView.js` to handle batch file uploads using promises. - Ensured that the `_.each` loop completes before proceeding to batch processing. Closes nceas/metacatui#2224 --- src/js/collections/DataPackage.js | 5 +- src/js/models/AppModel.js | 22 ++- src/js/views/DataItemView.js | 166 +++++++++++++++------- src/js/views/metadata/EML211EditorView.js | 1 - 4 files changed, 137 insertions(+), 57 deletions(-) diff --git a/src/js/collections/DataPackage.js b/src/js/collections/DataPackage.js index 9ff793ef2..fa2570da1 100644 --- a/src/js/collections/DataPackage.js +++ b/src/js/collections/DataPackage.js @@ -433,7 +433,6 @@ define([ } }, - /** * Fetches member models in batches to avoid fetching all members simultaneously. * @@ -442,6 +441,7 @@ define([ * @param {number} [batchSize=10] - The number of models to fetch in each batch. * @param {number} [timeout=5000] - The timeout for each fetch request in milliseconds. * @param {number} [maxRetries=3] - The maximum number of retries for each fetch request. + * @since 0.0.0 */ fetchMemberModels(models, index = 0, batchSize = 10, timeout = 5000, maxRetries = 3) { // Update the number of file metadata items being loaded @@ -823,8 +823,7 @@ define([ // Collection is set to false if (this.fetchModels !== false) { // Start fetching member models - this.fetchMemberModels.call(this, models, 0, MetacatUI.appModel.get("batchModeValue")); - + this.fetchMemberModels.call(this, models, 0, MetacatUI.appModel.get("batchSizeFetch")); } } catch (error) { // TODO: Handle the error diff --git a/src/js/models/AppModel.js b/src/js/models/AppModel.js index 8bfb70265..2be813509 100644 --- a/src/js/models/AppModel.js +++ b/src/js/models/AppModel.js @@ -2434,7 +2434,7 @@ define(["jquery", "underscore", "backbone"], function ($, _, Backbone) { */ packageFormat: "application%2Fbagit-1.0", /** - * Whether to batch requests to the DataONE API. This is an experimental feature + * Whether to batch fetch requests to the DataONE API. This is an experimental feature * and should be used with caution. If set to a number greater than 0, MetacatUI will * batch requests to the DataONE API and send them in groups of this size. This can * improve performance when making many requests to the DataONE API, but can also @@ -2447,8 +2447,26 @@ define(["jquery", "underscore", "backbone"], function ($, _, Backbone) { * @type {number} * @default 0 * @example 20 + * @since 0.0.0 + */ + batchSizeFetch: 0, + /** + * Whether to batch uploads to the DataONE API. This is an experimental feature + * and should be used with caution. If set to a number greater than 0, MetacatUI will + * batch uploads to the DataONE API and send them in groups of this size. This can + * improve performance when uploading many files to the DataONE API, but can also + * cause issues if the requests are too large or if the DataONE API is not able to + * handle the batched requests. + * + * Currently, this feature is only used in the DataPackageModel when uploading files + * to the DataONE API. + * + * @type {number} + * @default 0 + * @example 20 + * @since 0.0.0 */ - batchModeValue: 0, + batchSizeUpload: 0, }, MetacatUI.AppConfig, ), diff --git a/src/js/views/DataItemView.js b/src/js/views/DataItemView.js index 4b700e821..37d802e95 100644 --- a/src/js/views/DataItemView.js +++ b/src/js/views/DataItemView.js @@ -776,6 +776,84 @@ define([ event.preventDefault(); }, + /** + * Method to handle batch file uploads. + * This method processes files independently to avoid being slowed down by large files. + * + * @param {FileList} fileList - The list of files to be uploaded. + * @param {number} [batchSize=10] - The number of files to upload concurrently. + * @since 0.0.0 + */ + uploadFilesInBatch(fileList, batchSize = 10) { + const view = this; + let currentIndex = 0; // Index of the current file being processed + let activeUploads = 0; // Counter for the number of active uploads + + // If batchSize is 0, set it to the total number of files + if (batchSize == 0) batchSize = fileList.length; + + /** + * Function to upload the next file in the list. + * This function is called recursively to ensure that the number of concurrent uploads + * does not exceed the batch size. + */ + function uploadNextFile() { + // If all files have been processed, return + if (currentIndex >= fileList.length) { + return; + } + + // If the number of active uploads is less than the batch size, start a new upload + if (activeUploads < batchSize) { + const dataONEObject = fileList[currentIndex]; + currentIndex++; // Move to the next file + activeUploads++; // Increment the active uploads counter + + // Create a new Promise to handle the file upload + new Promise((resolve, reject) => { + // If the file needs to be uploaded and its checksum is not calculated + if (dataONEObject.get("uploadFile") && !dataONEObject.get("checksum")) { + // Stop listening to previous checksumCalculated events + dataONEObject.stopListening(dataONEObject, "checksumCalculated"); + // Listen to the checksumCalculated event to start the upload + dataONEObject.listenToOnce(dataONEObject, "checksumCalculated", () => { + dataONEObject.save(); // Save the file + // Listen to changes in the uploadStatus to resolve the Promise + dataONEObject.listenTo(dataONEObject, "change:uploadStatus", () => { + if (dataONEObject.get("uploadStatus") !== "p" && dataONEObject.get("uploadStatus") !== "q" && dataONEObject.get("uploadStatus") !== "l") { + resolve(); // Resolve the Promise when the upload is complete + } + }); + }); + try { + dataONEObject.calculateChecksum(); // Calculate the checksum + } catch (exception) { + reject(exception); // Reject the Promise if an error occurs + } + } else { + resolve(); // Resolve the Promise if the file does not need to be uploaded + } + }) + .then(() => { + activeUploads--; // Decrement the active uploads counter + uploadNextFile(); // Start the next file upload + }) + .catch((error) => { + console.error("Error uploading file:", error); + activeUploads--; // Decrement the active uploads counter + uploadNextFile(); // Start the next file upload + }); + + uploadNextFile(); // Start the next file upload + } + } + + // Start the initial batch of uploads + for (let i = 0; i < batchSize; i++) { + uploadNextFile(); + } + }, + /** With a file list from the file picker or drag and drop, add the files to the collection @@ -805,60 +883,46 @@ define([ if (typeof event.delegateTarget.dataset.id !== "undefined") { this.parentSciMeta = this.getParentScienceMetadata(event); this.collection = this.getParentDataPackage(event); + // Queue the files for upload + const queueFilesPromise = new Promise((resolve) => { + _.each( + fileList, + function (file) { + var uploadStatus = "l", + errorMessage = ""; + + if (file.size == 0) { + uploadStatus = "e"; + errorMessage = + "This is an empty file. It won't be included in the dataset."; + } - // Read each file, and make a DataONEObject - _.each( - fileList, - function (file) { - var uploadStatus = "l", - errorMessage = ""; - - if (file.size == 0) { - uploadStatus = "e"; - errorMessage = - "This is an empty file. It won't be included in the dataset."; - } - - var dataONEObject = new DataONEObject({ - synced: true, - type: "Data", - fileName: file.name, - size: file.size, - mediaType: file.type, - uploadFile: file, - uploadStatus: uploadStatus, - errorMessage: errorMessage, - isDocumentedBy: [this.parentSciMeta.id], - isDocumentedByModels: [this.parentSciMeta], - resourceMap: [this.collection.packageModel.id], - }); + var dataONEObject = new DataONEObject({ + synced: true, + type: "Data", + fileName: file.name, + size: file.size, + mediaType: file.type, + uploadFile: file, + uploadStatus: uploadStatus, + errorMessage: errorMessage, + isDocumentedBy: [this.parentSciMeta.id], + isDocumentedByModels: [this.parentSciMeta], + resourceMap: [this.collection.packageModel.id], + }); - // Add it to the parent collection - this.collection.add(dataONEObject); + // Add it to the parent collection + this.collection.add(dataONEObject); + }, + this, + ); + resolve(); + }); - // Asychronously calculate the checksum - if ( - dataONEObject.get("uploadFile") && - !dataONEObject.get("checksum") - ) { - dataONEObject.stopListening( - dataONEObject, - "checksumCalculated", - ); - dataONEObject.listenToOnce( - dataONEObject, - "checksumCalculated", - dataONEObject.save, - ); - try { - dataONEObject.calculateChecksum(); - } catch (exception) { - // TODO: Fail gracefully here for the user - } - } - }, - this, - ); + queueFilesPromise.then(() => { + // Call the batch upload method + this.uploadFilesInBatch(this.collection.models, MetacatUI.appModel.get('batchSizeUpload')); + }); } }, diff --git a/src/js/views/metadata/EML211EditorView.js b/src/js/views/metadata/EML211EditorView.js index e2a940691..ba6a0e7d6 100644 --- a/src/js/views/metadata/EML211EditorView.js +++ b/src/js/views/metadata/EML211EditorView.js @@ -552,7 +552,6 @@ define([ "change:numLoadingFiles", this.toggleEnableControls, ); - this.listenTo( MetacatUI.rootDataPackage.packageModel, "change:numLoadingFileMetadata", From 361fa73ee3328d8a1191b8966face062f83e8b7b Mon Sep 17 00:00:00 2001 From: Val Hendrix Date: Thu, 19 Dec 2024 15:28:39 -0800 Subject: [PATCH 3/6] chore: fixes linting issues in DataPackage,EML211EditorView --- src/js/collections/DataPackage.js | 41 +- src/js/views/DataItemView.js | 471 +++++++++++----------- src/js/views/metadata/EML211EditorView.js | 17 +- 3 files changed, 263 insertions(+), 266 deletions(-) diff --git a/src/js/collections/DataPackage.js b/src/js/collections/DataPackage.js index fa2570da1..cc4195259 100644 --- a/src/js/collections/DataPackage.js +++ b/src/js/collections/DataPackage.js @@ -1,4 +1,4 @@ -"use strict"; +"use strict"; define([ "jquery", @@ -435,12 +435,11 @@ define([ /** * Fetches member models in batches to avoid fetching all members simultaneously. - * * @param {Array} models - The array of member models to fetch. - * @param {number} [index=0] - The current index of the model being fetched. - * @param {number} [batchSize=10] - The number of models to fetch in each batch. - * @param {number} [timeout=5000] - The timeout for each fetch request in milliseconds. - * @param {number} [maxRetries=3] - The maximum number of retries for each fetch request. + * @param {number} [index] - The current index of the model being fetched. + * @param {number} [batchSize] - The number of models to fetch in each batch. + * @param {number} [timeout] - The timeout for each fetch request in milliseconds. + * @param {number} [maxRetries] - The maximum number of retries for each fetch request. * @since 0.0.0 */ fetchMemberModels(models, index = 0, batchSize = 10, timeout = 5000, maxRetries = 3) { @@ -454,15 +453,15 @@ define([ } // If batchSize is 0, set it to the total number of models - if (batchSize == 0) batchSize = models.length; + let batchSizeAdjust = batchSize; + if (batchSizeAdjust === 0) batchSizeAdjust = models.length; const collection = this; // Slice the models array to get the current batch - const batch = models.slice(index, index + batchSize); + const batch = models.slice(index, index + batchSizeAdjust); // Create an array of promises for fetching each model in the batch - const fetchPromises = batch.map((memberModel) => { - return new Promise((resolve, reject) => { + const fetchPromises = batch.map((memberModel) => new Promise((resolve, reject) => { const attemptFetch = (retriesLeft) => { // Create a promise for the fetch request const fetchPromise = new Promise((fetchResolve, fetchReject) => { @@ -473,8 +472,8 @@ define([ const newModel = collection.getMember(oldModel); // If the type of the old model is different from the new model - if (oldModel.type != newModel.type) { - if (newModel.type == "DataPackage") { + if (oldModel.type !== newModel.type) { + if (newModel.type === "DataPackage") { // If the new model is a DataPackage, replace the old model with the new one oldModel.trigger("replace", newModel); fetchResolve(); @@ -487,7 +486,7 @@ define([ collection.remove(oldModel); collection.add(fetchedModel); oldModel.trigger("replace", newModel); - if (newModel.type == "EML") collection.trigger("add:EML"); + if (newModel.type === "EML") collection.trigger("add:EML"); fetchResolve(); }); } @@ -495,7 +494,7 @@ define([ // If the type of the old model is the same as the new model, merge the new model into the collection newModel.set("synced", true); collection.add(newModel, { merge: true }); - if (newModel.type == "EML") collection.trigger("add:EML"); + if (newModel.type === "EML") collection.trigger("add:EML"); fetchResolve(); } }); @@ -505,7 +504,7 @@ define([ }); // Create a promise for the timeout - const timeoutPromise = new Promise((_, timeoutReject) => { + const timeoutPromise = new Promise((__, timeoutReject) => { setTimeout(() => timeoutReject(new Error("Fetch timed out")), timeout); }); @@ -515,11 +514,9 @@ define([ .catch((error) => { if (retriesLeft > 0) { // Retry the fetch if there are retries left - console.warn(`Retrying fetch for model: ${memberModel.id}, retries left: ${retriesLeft}, error: ${error}`); attemptFetch(retriesLeft - 1); } else { // Reject the promise if all retries are exhausted - console.error(`Failed to fetch model: ${memberModel.id} after ${maxRetries} retries, error: ${error}`); reject(error); } }); @@ -527,19 +524,22 @@ define([ // Start the fetch attempt with the maximum number of retries attemptFetch(maxRetries); - }); - }); + })); // Once all fetch promises are resolved, fetch the next batch Promise.allSettled(fetchPromises).then((results) => { const errors = results.filter(result => result.status === "rejected"); if (errors.length > 0) { + /* eslint-disable */ console.error("Error fetching member models:", errors); + /* eslint-enable */ } // Fetch the next batch of models - this.fetchMemberModels.call(collection, models, index + batchSize, batchSize, timeout, maxRetries); + this.fetchMemberModels.call(collection, models, index + batchSizeAdjust, batchSizeAdjust, timeout, maxRetries); }).catch((error) => { + /* eslint-disable */ console.error("Error fetching member models:", error); + /* eslint-enable */ }); }, @@ -602,7 +602,6 @@ define([ return Backbone.Collection.prototype.fetch .call(this, fetchOptions) .fail(() => - console.log("Fetch failed. Retrying with user login details..."), // If the initial fetch fails, retry with user login details retryFetch(), ); diff --git a/src/js/views/DataItemView.js b/src/js/views/DataItemView.js index 37d802e95..b26d56c66 100644 --- a/src/js/views/DataItemView.js +++ b/src/js/views/DataItemView.js @@ -8,7 +8,7 @@ define([ "views/DownloadButtonView", "text!templates/dataItem.html", "text!templates/dataItemHierarchy.html", -], function ( +], ( _, $, Backbone, @@ -18,19 +18,19 @@ define([ DownloadButtonView, DataItemTemplate, DataItemHierarchy, -) { +) => { /** - * @class DataItemView - * @classdesc A DataItemView represents a single data item in a data package as a single row of + * @class DataItemView + * @classdesc A DataItemView represents a single data item in a data package as a single row of a nested table. An item may represent a metadata object (as a folder), or a data object described by the metadata (as a file). Every metadata DataItemView has a resource map associated with it that describes the relationships between the aggregated metadata and data objects. - * @classcategory Views - * @constructor - * @screenshot views/DataItemView.png - */ - var DataItemView = Backbone.View.extend( + * @classcategory Views + * @class + * @screenshot views/DataItemView.png + */ + const DataItemView = Backbone.View.extend( /** @lends DataItemView.prototype */ { tagName: "tr", @@ -44,7 +44,7 @@ define([ /** The HTML template for a data item */ dataItemHierarchyTemplate: _.template(DataItemHierarchy), - //Templates + // Templates metricTemplate: _.template( "" + "" + @@ -88,9 +88,12 @@ define([ "click .downloadAction": "downloadFile", }, - /** Initialize the object - post constructor */ - initialize: function (options) { - if (typeof options == "undefined") var options = {}; + /** + * Initialize the object - post constructor + * @param options + */ + initialize (options) { + if (typeof options === "undefined") var options = {}; this.model = options.model || new DataONEObject(); this.currentlyViewing = options.currentlyViewing || null; @@ -105,26 +108,27 @@ define([ this.parentEditorView = options.parentEditorView || null; this.dataPackageId = options.dataPackageId || null; - if (!(typeof options.metricsModel == "undefined")) { + if (!(typeof options.metricsModel === "undefined")) { this.metricsModel = options.metricsModel; } }, - /** Renders a DataItemView for the given DataONEObject + /** + * Renders a DataItemView for the given DataONEObject * @param {DataONEObject} model */ - render: function (model) { - //Prevent duplicate listeners + render (model) { + // Prevent duplicate listeners this.stopListening(); if (this.itemType === "folder") { // Set the data-id for identifying events to model ids this.$el.attr( "data-id", - (this.itemPath ? this.itemPath : "") + "/" + this.itemName, + `${this.itemPath ? this.itemPath : "" }/${ this.itemName}`, ); this.$el.attr("data-parent", this.itemPath ? this.itemPath : ""); - this.$el.attr("data-category", "entities-" + this.itemName); + this.$el.attr("data-category", `entities-${ this.itemName}`); var attributes = new Object(); attributes.fileType = undefined; @@ -163,9 +167,9 @@ define([ } else { // Set the data-id for identifying events to model ids this.$el.attr("data-id", this.model.get("id")); - this.$el.attr("data-category", "entities-" + this.model.get("id")); + this.$el.attr("data-category", `entities-${ this.model.get("id")}`); - //Destroy the old tooltip + // Destroy the old tooltip this.$(".status .icon, .status .progress") .tooltip("hide") .tooltip("destroy"); @@ -181,12 +185,12 @@ define([ attributes.isMetadata = true; } - //Format the title + // Format the title if (Array.isArray(attributes.title)) { attributes.title = attributes.title[0]; } - //Set some defaults + // Set some defaults attributes.numAttributes = 0; attributes.entityIsValid = true; attributes.hasInvalidAttribute = false; @@ -198,7 +202,7 @@ define([ // Note: .canWrite is set here (at render) instead of at init // because render will get called a few times during page load // as the app updates what it knows about the object - let accessPolicy = this.model.get("accessPolicy"); + const accessPolicy = this.model.get("accessPolicy"); if (accessPolicy) { attributes.canWrite = accessPolicy.isAuthorized("write"); this.canWrite = attributes.canWrite; @@ -213,11 +217,11 @@ define([ this.canShare = this.canShareItem(); attributes.canShare = this.canShare; - //Get the number of attributes for this item + // Get the number of attributes for this item if (this.model.type != "EML") { - //Get the parent EML model + // Get the parent EML model if (this.parentEML) { - var parentEML = this.parentEML; + var {parentEML} = this; } else { var parentEML = MetacatUI.rootDataPackage.where({ id: Array.isArray(this.model.get("isDocumentedBy")) @@ -228,22 +232,22 @@ define([ if (Array.isArray(parentEML)) parentEML = parentEML[0]; - //If we found a parent EML model + // If we found a parent EML model if (parentEML && parentEML.type == "EML") { this.parentEML = parentEML; - //Find the EMLEntity model for this data item - var entity = + // Find the EMLEntity model for this data item + const entity = this.model.get("metadataEntity") || parentEML.getEntity(this.model); - //If we found an EMLEntity model + // If we found an EMLEntity model if (entity) { this.entity = entity; - //Get the file name from the metadata if it is not in the model + // Get the file name from the metadata if it is not in the model if (!this.model.get("fileName")) { - var fileName = ""; + let fileName = ""; if (entity.get("physicalObjectName")) fileName = entity.get("physicalObjectName"); @@ -254,12 +258,12 @@ define([ this.model.set("fileName", fileName); } - //Get the number of attributes for this entity + // Get the number of attributes for this entity attributes.numAttributes = entity.get("attributeList").length; - //Determine if the entity model is valid + // Determine if the entity model is valid attributes.entityIsValid = entity.isValid(); - //Listen to changes to certain attributes of this EMLEntity model + // Listen to changes to certain attributes of this EMLEntity model // to re-render this view this.stopListening(entity); this.listenTo( @@ -268,34 +272,34 @@ define([ this.render, ); - //Check if there are any invalid attribute models - //Also listen to each attribute model + // Check if there are any invalid attribute models + // Also listen to each attribute model _.each( entity.get("attributeList"), function (attr) { - var isValid = attr.isValid(); + const isValid = attr.isValid(); - //Mark that this entity has at least one invalid attribute + // Mark that this entity has at least one invalid attribute if (!attributes.hasInvalidAttribute && !isValid) attributes.hasInvalidAttribute = true; this.stopListening(attr); - //Listen to when the validation status changes and rerender + // Listen to when the validation status changes and rerender if (isValid) this.listenTo(attr, "invalid", this.render); else this.listenTo(attr, "valid", this.render); }, this, ); - //If there are no attributes now, rerender when one is added + // If there are no attributes now, rerender when one is added this.listenTo(entity, "change:attributeList", this.render); } else { - //Rerender when an entity is added + // Rerender when an entity is added this.listenTo(this.model, "change:entities", this.render); } } else { - //When the package is complete, rerender + // When the package is complete, rerender this.listenTo( MetacatUI.rootDataPackage, "add:EML", @@ -306,14 +310,14 @@ define([ this.$el.html(this.template(attributes)); - //Initialize dropdowns + // Initialize dropdowns this.$el.find(".dropdown-toggle").dropdown(); - //Render the Share button + // Render the Share button this.renderShareControl(); if (this.model.get("type") == "Metadata") { - //Add the title data-attribute attribute to the name cell + // Add the title data-attribute attribute to the name cell this.$el.find(".name").attr("data-attribute", "title"); this.$el.addClass("folder"); } else { @@ -332,12 +336,12 @@ define([ container: "body", }); - //Check if the data package is in progress of being uploaded + // Check if the data package is in progress of being uploaded this.toggleSaving(); - //Create tooltips based on the upload status - var uploadStatus = this.model.get("uploadStatus"), - errorMessage = this.model.get("errorMessage"); + // Create tooltips based on the upload status + const uploadStatus = this.model.get("uploadStatus"); + let errorMessage = this.model.get("errorMessage"); // Use a friendlier message for 401 errors (the one returned is a little hard to understand) if (this.model.get("sysMetaErrorCode") == 401) { @@ -345,32 +349,32 @@ define([ /** @todo Do an object update when someone has write permission but not changePermission and is trying to change the system metadata (but not the access policy) */ if (accessPolicy && accessPolicy.isAuthorized("write")) { errorMessage = - "The owner of this data file has not given you permission to rename it or change the " + - MetacatUI.appModel.get("accessPolicyName") + - "."; + `The owner of this data file has not given you permission to rename it or change the ${ + MetacatUI.appModel.get("accessPolicyName") + }.`; // Otherwise, assume they only have read access } else { errorMessage = - "The owner of this data file has not given you permission to edit this data file or change the " + - MetacatUI.appModel.get("accessPolicyName") + - "."; + `The owner of this data file has not given you permission to edit this data file or change the ${ + MetacatUI.appModel.get("accessPolicyName") + }.`; } } // When there's an error or a warninig if (uploadStatus == "e" && errorMessage) { - var tooltipClass = uploadStatus == "e" ? "error" : ""; + const tooltipClass = uploadStatus == "e" ? "error" : ""; this.$(".status .icon").tooltip({ placement: "top", trigger: "hover", html: true, title: - "
Issue saving:
" + - errorMessage + - "
", + `
Issue saving:
${ + errorMessage + }
`, container: "body", }); @@ -424,20 +428,20 @@ define([ this.$el.addClass("loading"); } else if (uploadStatus == "p") { - var model = this.model; + var {model} = this; this.$(".status .progress").tooltip({ placement: "top", trigger: "hover", html: true, - title: function () { + title () { if (model.get("numSaveAttempts") > 0) { return ( - "
Something went wrong during upload.
Trying again... (attempt " + - (model.get("numSaveAttempts") + 1) + - " of 3)
" + `
Something went wrong during upload.
Trying again... (attempt ${ + model.get("numSaveAttempts") + 1 + } of 3)
` ); - } else if (model.get("uploadProgress")) { + } if (model.get("uploadProgress")) { var percentDone = model.get("uploadProgress").toString(); if (percentDone.indexOf(".") > -1) percentDone = percentDone.substring( @@ -447,9 +451,9 @@ define([ } else var percentDone = "0"; return ( - "
Uploading: " + - percentDone + - "%
" + `
Uploading: ${ + percentDone + }%
` ); }, container: "body", @@ -460,21 +464,21 @@ define([ this.$el.removeClass("loading"); } - //Listen to changes to the upload progress of this object + // Listen to changes to the upload progress of this object this.listenTo( this.model, "change:uploadProgress", this.showUploadProgress, ); - //Listen to changes to the upload status of the entire package + // Listen to changes to the upload status of the entire package this.listenTo( MetacatUI.rootDataPackage.packageModel, "change:uploadStatus", this.toggleSaving, ); - //listen for changes to rerender the view + // listen for changes to rerender the view this.listenTo( this.model, "change:fileName change:title change:id change:formatType " + @@ -484,7 +488,7 @@ define([ ); // render changes to the item var view = this; - this.listenTo(this.model, "replace", function (newModel) { + this.listenTo(this.model, "replace", (newModel) => { view.model = newModel; view.render(); }); @@ -496,28 +500,28 @@ define([ this.model.getFormat() == "metadata" || this.model.get("id") == this.currentlyViewing ) { - attributes.title = "Metadata: " + this.model.get("fileName"); + attributes.title = `Metadata: ${ this.model.get("fileName")}`; attributes.icon = "icon-file-text"; attributes.metricIcon = "icon-eye-open"; this.isMetadata = true; this.$el.attr("data-packageId", this.dataPackageId); } - var objectTitleTooltip = + const objectTitleTooltip = attributes.title || attributes.fileName || attributes.id; attributes.objectTitle = objectTitleTooltip.length > 150 - ? objectTitleTooltip.slice(0, 75) + - "..." + + ? `${objectTitleTooltip.slice(0, 75) + }...${ objectTitleTooltip.slice( objectTitleTooltip.length - 75, objectTitleTooltip.length, - ) + )}` : objectTitleTooltip; attributes.fileType = this.model.getFormat(); attributes.isFolder = false; - //Determine the icon type based on format type + // Determine the icon type based on format type if (this.model.getFormat() == "program") attributes.icon = "icon-code"; else if (this.model.getFormat() == "data") @@ -530,8 +534,8 @@ define([ attributes.id = this.model.get("id"); attributes.memberRowMetrics = null; - var metricToolTip = null, - view = this; + let metricToolTip = null; + var view = this; // Insert metrics for this item, // if the model has already been fethced. @@ -543,8 +547,8 @@ define([ // If the fetch() is still in progress. this.listenTo(this.metricsModel, "sync", function () { metricToolTip = this.getMemberRowMetrics(view.id); - let readsCell = this.$( - '.metrics-count.downloads[data-id="' + view.id + '"]', + const readsCell = this.$( + `.metrics-count.downloads[data-id="${ view.id }"]`, ); metricToolTip = view.getMemberRowMetrics(view.id); if (typeof metricToolTip !== "undefined" && metricToolTip) @@ -586,7 +590,7 @@ define([ } // var parent = itemPathParts[itemPathParts.length - 2]; - var parentPath = itemPathParts.slice(0, -1).join("/"); + const parentPath = itemPathParts.slice(0, -1).join("/"); if (parentPath !== undefined) { this.$el.attr("data-parent", parentPath); @@ -610,11 +614,11 @@ define([ const id = this.model.get("id"); const infoLink = - MetacatUI.root + - "/view/" + - encodeURIComponent(this.currentlyViewing) + - "#" + - encodeURIComponent(id); + `${MetacatUI.root + }/view/${ + encodeURIComponent(this.currentlyViewing) + }#${ + encodeURIComponent(id)}`; attributes.moreInfoLink = infoLink; attributes.insertInfoIcon = this.insertInfoIcon; @@ -652,16 +656,16 @@ define([ * Renders a button that opens the AccessPolicyView for editing permissions on this data item * @since 2.15.0 */ - renderShareControl: function () { - //Get the Share button element - var shareButton = this.$(".sharing button"); + renderShareControl () { + // Get the Share button element + const shareButton = this.$(".sharing button"); if ( this.parentEditorView && this.parentEditorView.isAccessPolicyEditEnabled() ) { - //Start a title for the button tooltip - var sharebuttonTitle; + // Start a title for the button tooltip + let sharebuttonTitle; // If the user is not authorized to change the permissions of // this object, then disable the share button @@ -687,7 +691,7 @@ define([ }, /** Close the view and remove it from the DOM */ - onClose: function () { + onClose () { this.remove(); // remove for the DOM, stop listening this.off(); // remove callbacks, prevent zombies }, @@ -695,16 +699,16 @@ define([ /** Generate a unique id for each data item in the table TODO: This could be replaced with the DataONE identifier - */ - generateId: function () { - var idStr = ""; // the id to return - var length = 30; // the length of the generated string - var chars = + */ + generateId () { + let idStr = ""; // the id to return + const length = 30; // the length of the generated string + const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz".split( "", ); - for (var i = 0; i < length; i++) { + for (let i = 0; i < length; i++) { idStr += chars[Math.floor(Math.random() * chars.length)]; } @@ -713,21 +717,20 @@ define([ /** * Update the folder name based on the scimeta title - * * @param e The event triggering this method */ - updateName: function (e) { - var enteredText = this.cleanInput($(e.target).text().trim()); + updateName (e) { + const enteredText = this.cleanInput($(e.target).text().trim()); // Set the title if this item is metadata or set the file name // if its not if (this.model.get("type") == "Metadata") { - var title = this.model.get("title"); + const title = this.model.get("title"); // Get the current title which is either an array of titles // or a single string. When it's an array of strings, we // use the first as the canonical title - var currentTitle = Array.isArray(title) ? title[0] : title; + const currentTitle = Array.isArray(title) ? title[0] : title; // Don't set the title if it hasn't changed or is empty if ( @@ -739,7 +742,7 @@ define([ // that aren't Arrays into Arrays if ( (Array.isArray(title) && title.length < 2) || - typeof title == "string" + typeof title === "string" ) { this.model.set("title", [enteredText]); } else { @@ -763,10 +766,10 @@ define([ Handle the add file event, showing the file picker dialog Multiple files are allowed using the shift and or option/alt key @param {Event} event - */ - handleAddFiles: function (event) { + */ + handleAddFiles (event) { event.stopPropagation(); - var fileUploadElement = this.$(".file-upload"); + const fileUploadElement = this.$(".file-upload"); fileUploadElement.val(""); @@ -779,9 +782,8 @@ define([ /** * Method to handle batch file uploads. * This method processes files independently to avoid being slowed down by large files. - * * @param {FileList} fileList - The list of files to be uploaded. - * @param {number} [batchSize=10] - The number of files to upload concurrently. + * @param {number} [batchSize] - The number of files to upload concurrently. * @since 0.0.0 */ uploadFilesInBatch(fileList, batchSize = 10) { @@ -858,11 +860,11 @@ define([ With a file list from the file picker or drag and drop, add the files to the collection @param {Event} event - */ - addFiles: function (event) { - var fileList, // The list of chosen files - parentDataPackage, // The id of the first resource of this row's scimeta - self = this; // A reference to this view + */ + addFiles (event) { + let fileList; // The list of chosen files + let parentDataPackage; // The id of the first resource of this row's scimeta + const self = this; // A reference to this view event.stopPropagation(); event.preventDefault(); @@ -871,11 +873,9 @@ define([ fileList = event.originalEvent.dataTransfer.files; // handle file picker files - } else { - if (event.target) { + } else if (event.target) { fileList = event.target.files; } - } this.$el.removeClass("droppable"); // Find the correct collection to add to. Use JQuery's delegateTarget @@ -888,8 +888,8 @@ define([ _.each( fileList, function (file) { - var uploadStatus = "l", - errorMessage = ""; + let uploadStatus = "l"; + let errorMessage = ""; if (file.size == 0) { uploadStatus = "e"; @@ -897,15 +897,15 @@ define([ "This is an empty file. It won't be included in the dataset."; } - var dataONEObject = new DataONEObject({ + const dataONEObject = new DataONEObject({ synced: true, type: "Data", fileName: file.name, size: file.size, mediaType: file.type, uploadFile: file, - uploadStatus: uploadStatus, - errorMessage: errorMessage, + uploadStatus, + errorMessage, isDocumentedBy: [this.parentSciMeta.id], isDocumentedByModels: [this.parentSciMeta], resourceMap: [this.collection.packageModel.id], @@ -927,13 +927,16 @@ define([ }, /** Show the drop zone for this row in the table */ - showDropzone: function () { + showDropzone () { if (this.model.get("type") !== "Metadata") return; this.$el.addClass("droppable"); }, - /** Hide the drop zone for this row in the table */ - hideDropzone: function (event) { + /** + * Hide the drop zone for this row in the table + * @param event + */ + hideDropzone (event) { if (this.model.get("type") !== "Metadata") return; this.$el.removeClass("droppable"); }, @@ -944,10 +947,9 @@ define([ * * Called indirectly via the "click" event on elements with the * class .replaceFile. See this View's events map. - * * @param {MouseEvent} event Browser Click event */ - handleReplace: function (event) { + handleReplace (event) { event.stopPropagation(); // Stop immediately if we know the user doesn't have privs @@ -956,7 +958,7 @@ define([ return; } - var fileReplaceElement = $(event.target) + const fileReplaceElement = $(event.target) .parents(".dropdown-menu") .children(".file-replace"); @@ -985,10 +987,9 @@ define([ * mistakes that would cause the editor to get into a broken state. * On error, we attempt to return the editor back to its pre-replace * state. - * * @param {Event} event */ - replaceFile: function (event) { + replaceFile (event) { event.stopPropagation(); event.preventDefault(); @@ -996,7 +997,7 @@ define([ return; } - var fileList = event.target.files; + const fileList = event.target.files; // Pre-check fileList value to make sure we can work with it if (fileList.length != 1) { @@ -1010,11 +1011,11 @@ define([ } // Save uploadStatus for reverting if need to - var oldUploadStatus = this.model.get("uploadStatus"); + const oldUploadStatus = this.model.get("uploadStatus"); - var file = fileList[0], - uploadStatus = "q", - errorMessage = ""; + const file = fileList[0]; + let uploadStatus = "q"; + let errorMessage = ""; if (file.size == 0) { uploadStatus = "e"; @@ -1030,7 +1031,7 @@ define([ } // Copy model attributes aside for reverting on error - var newAttributes = { + const newAttributes = { synced: false, fileName: file.name, size: file.size, @@ -1038,15 +1039,15 @@ define([ uploadFile: file, hasContentChanges: true, checksum: null, - uploadStatus: uploadStatus, + uploadStatus, sysMetaUploadStatus: "c", // I set this so DataPackage::save // wouldn't try to update the sysmeta after the update - errorMessage: errorMessage, + errorMessage, }; // Save a copy of the attributes we're changing so we can revert // later if we encounter an exception - var oldAttributes = {}; + const oldAttributes = {}; _.each( Object.keys(newAttributes), function (k) { @@ -1055,7 +1056,7 @@ define([ this, ); - oldAttributes["uploadStatus"] = oldUploadStatus; + oldAttributes.uploadStatus = oldUploadStatus; try { this.model.set(newAttributes); @@ -1066,7 +1067,7 @@ define([ // Grab a reference to the entity in the EML for the object // we're replacing this.parentSciMeta = this.getParentScienceMetadata(event); - var entity = null; + let entity = null; if (this.parentSciMeta) { entity = this.parentSciMeta.getEntity(this.model); @@ -1094,7 +1095,7 @@ define([ MetacatUI.rootDataPackage.packageModel.set("changed", true); // Last, provided a visual indication the replace was completed - var describeButton = this.$el + const describeButton = this.$el .children(".controls") .children(".btn-group") .children("button.edit") @@ -1104,14 +1105,14 @@ define([ return; } - var oldText = describeButton.html(); + const oldText = describeButton.html(); describeButton.html(' Replaced'); - var previousBtnClasses = describeButton.attr("class"); + const previousBtnClasses = describeButton.attr("class"); describeButton.removeClass("warning error").addClass("message"); - window.setTimeout(function () { + window.setTimeout(() => { describeButton.html(oldText); describeButton.addClass(previousBtnClasses).removeClass("message"); }, 3000); @@ -1127,18 +1128,18 @@ define([ this.render(); } - return; + }, /** Handle remove events for this row in the data package table @param {Event} event - */ - handleRemove: function (event) { - var eventId, // The id of the row of this event - removalIds = [], // The list of target ids to remove - dataONEObject, // The model represented by this row - documents; // The list of ids documented by this row (if meta) + */ + handleRemove (event) { + let eventId; // The id of the row of this event + const removalIds = []; // The list of target ids to remove + let dataONEObject; // The model represented by this row + let documents; // The list of ids documented by this row (if meta) event.stopPropagation(); event.preventDefault(); @@ -1177,9 +1178,9 @@ define([ _.each(documents, removalIds.push()); } } - //Data objects may need to be removed from the EML model entities list + // Data objects may need to be removed from the EML model entities list else if (dataONEObject && this.parentSciMeta.type == "EML") { - var matchingEntity = this.parentSciMeta.getEntity(dataONEObject); + const matchingEntity = this.parentSciMeta.getEntity(dataONEObject); if (matchingEntity) this.parentSciMeta.removeEntity(matchingEntity); } @@ -1188,8 +1189,8 @@ define([ _.each( removalIds, function (id) { - var documents = this.parentSciMeta.get("documents"); - var index = documents.indexOf(id); + const documents = this.parentSciMeta.get("documents"); + const index = documents.indexOf(id); if (index > -1) { this.parentSciMeta.get("documents").splice(index, 1); } @@ -1215,11 +1216,11 @@ define([ * data or metadata row of the UI event * @param {Event} event */ - getParentScienceMetadata: function (event) { - var parentMetadata, // The parent metadata array in the collection - eventModels, // The models associated with the event's table row - eventModel, // The model associated with the event's table row - parentSciMeta; // The parent science metadata for the event model + getParentScienceMetadata (event) { + let parentMetadata; // The parent metadata array in the collection + let eventModels; // The models associated with the event's table row + let eventModel; // The model associated with the event's table row + let parentSciMeta; // The parent science metadata for the event model if (typeof event.delegateTarget.dataset.id !== "undefined") { eventModels = MetacatUI.rootDataPackage.where({ @@ -1235,7 +1236,7 @@ define([ // Is this a Data or Metadata model? if (eventModel.get && eventModel.get("type") === "Metadata") { return eventModel; - } else { + } // It's data, get the parent scimeta parentMetadata = MetacatUI.rootDataPackage.where({ id: Array.isArray(eventModel.get("isDocumentedBy")) @@ -1246,15 +1247,15 @@ define([ if (parentMetadata.length > 0) { parentSciMeta = parentMetadata[0]; return parentSciMeta; - } else { - //If there is only one metadata model in the root data package, then use that metadata model - var metadataModels = MetacatUI.rootDataPackage.where({ + } + // If there is only one metadata model in the root data package, then use that metadata model + const metadataModels = MetacatUI.rootDataPackage.where({ type: "Metadata", }); if (metadataModels.length == 1) return metadataModels[0]; - } - } + + } }, @@ -1263,8 +1264,8 @@ define([ * data or metadata row of the UI event * @param {Event} event */ - getParentDataPackage: function (event) { - var parentSciMeta, parenResourceMaps, parentResourceMapId; + getParentDataPackage (event) { + let parentSciMeta; let parenResourceMaps; let parentResourceMapId; if (typeof event.delegateTarget.dataset.id !== "undefined") { parentSciMeta = this.getParentScienceMetadata(event); @@ -1295,28 +1296,28 @@ define([ return MetacatUI.rootDataPackage; // A nested package - } else { + } return MetacatUI.rootDataPackage.where({ id: parentResourceMapId, })[0]; - } + } }, /** * Removes invalid characters and formatting from the given input string * @param {string} input The string to clean - * @return {string} + * @returns {string} */ - cleanInput: function (input) { + cleanInput (input) { // 1. remove line breaks / Mso classes - var stringStripper = /(\n|\r| class=(")?Mso[a-zA-Z]+(")?)/g; - var output = input.replace(stringStripper, " "); + const stringStripper = /(\n|\r| class=(")?Mso[a-zA-Z]+(")?)/g; + let output = input.replace(stringStripper, " "); // 2. strip Word generated HTML comments - var commentSripper = new RegExp("", "g"); + const commentSripper = new RegExp("", "g"); output = output.replace(commentSripper, ""); - var tagStripper = new RegExp( + let tagStripper = new RegExp( "<(/)*(meta|link|span|\\?xml:|st1:|o:|font)(.*?)>", "gi", ); @@ -1325,7 +1326,7 @@ define([ output = output.replace(tagStripper, ""); // 4. Remove everything in between and including tags '' - var badTags = [ + const badTags = [ "style", "script", "applet", @@ -1336,17 +1337,17 @@ define([ for (var i = 0; i < badTags.length; i++) { tagStripper = new RegExp( - "<" + badTags[i] + ".*?" + badTags[i] + "(.*?)>", + `<${ badTags[i] }.*?${ badTags[i] }(.*?)>`, "gi", ); output = output.replace(tagStripper, ""); } // 5. remove attributes ' style="..."' - var badAttributes = ["style", "start"]; + const badAttributes = ["style", "start"]; for (var i = 0; i < badAttributes.length; i++) { - var attributeStripper = new RegExp( - " " + badAttributes[i] + '="(.*?)"', + const attributeStripper = new RegExp( + ` ${ badAttributes[i] }="(.*?)"`, "gi", ); output = output.replace(attributeStripper, ""); @@ -1360,7 +1361,7 @@ define([ /** * Style this table row to indicate it will be removed */ - previewRemove: function () { + previewRemove () { this.$el.toggleClass("remove-preview"); }, @@ -1369,8 +1370,8 @@ define([ * an 'empty' class, and remove it when the user focuses back out. * @param {Event} e */ - emptyName: function (e) { - var editableCell = this.$(".canRename [contenteditable]"); + emptyName (e) { + const editableCell = this.$(".canRename [contenteditable]"); editableCell.tooltip("hide"); @@ -1379,7 +1380,7 @@ define([ .attr("data-original-text", editableCell.text().trim()) .text("") .addClass("empty") - .on("focusout", function () { + .on("focusout", () => { if (!editableCell.text()) editableCell .text(editableCell.attr("data-original-text")) @@ -1390,38 +1391,35 @@ define([ /** * Changes the access policy of a data object based on user input. - * * @param {Event} e - The event that triggered this function as a callback */ - changeAccessPolicy: function (e) { + changeAccessPolicy (e) { if (typeof e === "undefined" || !e) return; - var accessPolicy = this.model.get("accessPolicy"); + const accessPolicy = this.model.get("accessPolicy"); - var makePublic = $(e.target).prop("checked"); + const makePublic = $(e.target).prop("checked"); - //If the user has chosen to make this object private + // If the user has chosen to make this object private if (!makePublic) { if (accessPolicy) { - //Make the access policy private + // Make the access policy private accessPolicy.makePrivate(); } else { - //Create an access policy from the default settings + // Create an access policy from the default settings this.model.createAccessPolicy(); - //Make the access policy private + // Make the access policy private this.model.get("accessPolicy").makePrivate(); } - } else { - if (accessPolicy) { - //Make the access policy public + } else if (accessPolicy) { + // Make the access policy public accessPolicy.makePublic(); } else { - //Create an access policy from the default settings + // Create an access policy from the default settings this.model.createAccessPolicy(); - //Make the access policy public + // Make the access policy public this.model.get("accessPolicy").makePublic(); } - } }, /** @@ -1429,28 +1427,28 @@ define([ * @param {string} attr The modal attribute that has been validated * @param {string} errorMsg The validation error message to display */ - showValidation: function (attr, errorMsg) { - //Find the element that is required - var requiredEl = this.$("[data-category='" + attr + "']").addClass( + showValidation (attr, errorMsg) { + // Find the element that is required + const requiredEl = this.$(`[data-category='${ attr }']`).addClass( "error", ); - //When it is updated, remove the error styling - this.listenToOnce(this.model, "change:" + attr, this.hideRequired); + // When it is updated, remove the error styling + this.listenToOnce(this.model, `change:${ attr}`, this.hideRequired); }, /** * Hides the 'required' styling from this view */ - hideRequired: function () { - //Remove the error styling + hideRequired () { + // Remove the error styling this.$("[contenteditable].error").removeClass("error"); }, /** * Show the data item as saving */ - showSaving: function () { + showSaving () { this.$(".controls button").prop("disabled", true); if (this.model.get("type") != "Metadata") @@ -1464,17 +1462,17 @@ define([ /** * Hides the styles applied in {@link DataItemView#showSaving} */ - hideSaving: function () { + hideSaving () { this.$(".controls button").prop("disabled", false); this.$(".disable-layer").remove(); - //Make the name cell editable again + // Make the name cell editable again this.$(".canRename > div").prop("contenteditable", true); this.$el.removeClass("error-saving"); }, - toggleSaving: function () { + toggleSaving () { if ( this.model.get("uploadStatus") == "p" || this.model.get("uploadStatus") == "l" || @@ -1492,13 +1490,13 @@ define([ /** * Shows the current progress of the file upload */ - showUploadProgress: function () { + showUploadProgress () { if (this.model.get("numSaveAttempts") > 0) { this.$(".progress .bar").css("width", "100%"); } else { this.$(".progress .bar").css( "width", - this.model.get("uploadProgress") + "%", + `${this.model.get("uploadProgress") }%`, ); } }, @@ -1513,65 +1511,64 @@ define([ * not. If metadata (ie type is EML), also checks that the resource * map can be shared. Otherwise, only checks if the data item can be * shared. - * - * @return {boolean} Whether the item can be shared + * @returns {boolean} Whether the item can be shared * @since 2.15.0 */ - canShareItem: function () { + canShareItem () { if (this.parentEditorView) { if (this.parentEditorView.isAccessPolicyEditEnabled()) { if (this.model.type === "EML") { // Check whether we can share the resource map - var pkgModel = MetacatUI.rootDataPackage.packageModel, - pkgAccessPolicy = pkgModel.get("accessPolicy"); + const pkgModel = MetacatUI.rootDataPackage.packageModel; + const pkgAccessPolicy = pkgModel.get("accessPolicy"); - var canShareResourceMap = + const canShareResourceMap = pkgModel.isNew() || (pkgAccessPolicy && pkgAccessPolicy.isAuthorized("changePermission")); // Check whether we can share the metadata - var modelAccessPolicy = this.model.get("accessPolicy"); - var canShareMetadata = + const modelAccessPolicy = this.model.get("accessPolicy"); + const canShareMetadata = this.model.isNew() || (modelAccessPolicy && modelAccessPolicy.isAuthorized("changePermission")); // Only return true if we can share both return canShareMetadata && canShareResourceMap; - } else { + } return ( this.model.get("accessPolicy") && this.model.get("accessPolicy").isAuthorized("changePermission") ); - } + } } }, - downloadFile: function (e) { + downloadFile (e) { this.downloadButtonView.download(e); }, // Member row metrics for the package table // Retrieving information from the Metrics Model's result details - getMemberRowMetrics: function (id) { + getMemberRowMetrics (id) { if (typeof this.metricsModel !== "undefined") { - var metricsResultDetails = this.metricsModel.get("resultDetails"); + const metricsResultDetails = this.metricsModel.get("resultDetails"); if ( typeof metricsResultDetails !== "undefined" && metricsResultDetails ) { - var metricsPackageDetails = - metricsResultDetails["metrics_package_counts"]; + const metricsPackageDetails = + metricsResultDetails.metrics_package_counts; - var objectLevelMetrics = metricsPackageDetails[id]; + const objectLevelMetrics = metricsPackageDetails[id]; if (typeof objectLevelMetrics !== "undefined") { if (this.isMetadata) { - var reads = objectLevelMetrics["viewCount"]; + var reads = objectLevelMetrics.viewCount; } else { - var reads = objectLevelMetrics["downloadCount"]; + var reads = objectLevelMetrics.downloadCount; } } else { var reads = 0; diff --git a/src/js/views/metadata/EML211EditorView.js b/src/js/views/metadata/EML211EditorView.js index ba6a0e7d6..5a64499a3 100644 --- a/src/js/views/metadata/EML211EditorView.js +++ b/src/js/views/metadata/EML211EditorView.js @@ -552,6 +552,7 @@ define([ "change:numLoadingFiles", this.toggleEnableControls, ); + this.stopListening(MetacatUI.rootDataPackage.packageModel, "change:numLoadingFileMetadata"); this.listenTo( MetacatUI.rootDataPackage.packageModel, "change:numLoadingFileMetadata", @@ -1196,20 +1197,20 @@ define([ */ toggleEnableControls() { if (MetacatUI.rootDataPackage.packageModel.get("isLoadingFiles")) { - let noun = + const noun = MetacatUI.rootDataPackage.packageModel.get("numLoadingFiles") > 1 ? " files" : " file"; this.disableControls( - "Waiting for " + - MetacatUI.rootDataPackage.packageModel.get("numLoadingFiles") + - noun + - " to upload...", + `Waiting for ${ + MetacatUI.rootDataPackage.packageModel.get("numLoadingFiles") + }${noun + } to upload...`, ); } else if (MetacatUI.rootDataPackage.packageModel.get("numLoadingFileMetadata") >0) { - this.disableControls("Waiting for " + - MetacatUI.rootDataPackage.packageModel.get("numLoadingFileMetadata") + - " file metadata to load..."); + this.disableControls(`Waiting for ${ + MetacatUI.rootDataPackage.packageModel.get("numLoadingFileMetadata") + } file metadata to load...`); } else { this.enableControls(); } From 4fd253e59754e24d683e5be52d10da85c231ae6a Mon Sep 17 00:00:00 2001 From: Val Hendrix Date: Thu, 19 Dec 2024 19:47:13 -0800 Subject: [PATCH 4/6] test: Adds unit tests for DataItemView,DataPackage - new test spec for DataItemView. Specifically tests uploadFilesInBatch and addFiles in relation to NCEAS/metacatui#2224 - Adds to test spec for DataPackage. Specifically tests fetchMemberModels functionality in relation to NCEAS/metacatui#2547 - Ensures comments provide context and purpose for each action in the tests --- src/js/collections/DataPackage.js | 2 +- src/js/views/DataItemView.js | 20 +-- test/config/tests.json | 3 +- .../unit/collections/DataPackage.spec.js | 83 +++++++++- test/js/specs/unit/views/DataItemView.spec.js | 143 ++++++++++++++++++ 5 files changed, 235 insertions(+), 16 deletions(-) create mode 100644 test/js/specs/unit/views/DataItemView.spec.js diff --git a/src/js/collections/DataPackage.js b/src/js/collections/DataPackage.js index cc4195259..c2a6eea5c 100644 --- a/src/js/collections/DataPackage.js +++ b/src/js/collections/DataPackage.js @@ -454,7 +454,7 @@ define([ // If batchSize is 0, set it to the total number of models let batchSizeAdjust = batchSize; - if (batchSizeAdjust === 0) batchSizeAdjust = models.length; + if (batchSizeAdjust === 0 && index === 0) batchSizeAdjust = models.length; const collection = this; // Slice the models array to get the current batch diff --git a/src/js/views/DataItemView.js b/src/js/views/DataItemView.js index b26d56c66..958a96373 100644 --- a/src/js/views/DataItemView.js +++ b/src/js/views/DataItemView.js @@ -792,7 +792,7 @@ define([ let activeUploads = 0; // Counter for the number of active uploads // If batchSize is 0, set it to the total number of files - if (batchSize == 0) batchSize = fileList.length; + if (batchSize === 0) batchSize = fileList.length; /** * Function to upload the next file in the list. @@ -1128,7 +1128,7 @@ define([ this.render(); } - + }, /** @@ -1236,7 +1236,7 @@ define([ // Is this a Data or Metadata model? if (eventModel.get && eventModel.get("type") === "Metadata") { return eventModel; - } + } // It's data, get the parent scimeta parentMetadata = MetacatUI.rootDataPackage.where({ id: Array.isArray(eventModel.get("isDocumentedBy")) @@ -1247,15 +1247,15 @@ define([ if (parentMetadata.length > 0) { parentSciMeta = parentMetadata[0]; return parentSciMeta; - } + } // If there is only one metadata model in the root data package, then use that metadata model const metadataModels = MetacatUI.rootDataPackage.where({ type: "Metadata", }); if (metadataModels.length == 1) return metadataModels[0]; - - + + } }, @@ -1296,11 +1296,11 @@ define([ return MetacatUI.rootDataPackage; // A nested package - } + } return MetacatUI.rootDataPackage.where({ id: parentResourceMapId, })[0]; - + } }, @@ -1536,12 +1536,12 @@ define([ // Only return true if we can share both return canShareMetadata && canShareResourceMap; - } + } return ( this.model.get("accessPolicy") && this.model.get("accessPolicy").isAuthorized("changePermission") ); - + } } }, diff --git a/test/config/tests.json b/test/config/tests.json index 2f79cb313..e626f2c7f 100644 --- a/test/config/tests.json +++ b/test/config/tests.json @@ -91,7 +91,8 @@ "./js/specs/unit/collections/BioontologyResults.spec.js", "./js/specs/unit/models/ontologies/BioontologyOntology.spec.js", "./js/specs/unit/models/accordion/Accordion.spec.js", - "./js/specs/unit/models/accordion/AccordionItem.spec.js" + "./js/specs/unit/models/accordion/AccordionItem.spec.js", + "./js/specs/unit/views/DataItemView.spec.js" ], "integration": [ "./js/specs/integration/collections/SolrResults.spec.js", diff --git a/test/js/specs/unit/collections/DataPackage.spec.js b/test/js/specs/unit/collections/DataPackage.spec.js index 54fba03bb..7d1aaf833 100644 --- a/test/js/specs/unit/collections/DataPackage.spec.js +++ b/test/js/specs/unit/collections/DataPackage.spec.js @@ -1,6 +1,7 @@ -define(["../../../../../../../../src/js/collections/DataPackage"], function ( - DataPackage, -) { +define([ + "../../../../../../../../src/js/collections/DataPackage", + "../../../../../../../../src/js/models/DataONEObject", +], function (DataPackage, DataONEObject) { var expect = chai.expect; describe("DataPackage Test Suite", function () { @@ -39,5 +40,79 @@ define(["../../../../../../../../src/js/collections/DataPackage"], function ( expect(result).to.equal("folder1/folder2/file.txt"); }); }); + + describe("fetchMemberModels", function () { + this.timeout(30000); // Increase timeout to 30 seconds + + it("should fetch member models successfully", function (done) { + const models = [new DataONEObject(), new DataONEObject()]; + const originalFetch = DataONEObject.prototype.fetch; + let fetchCallCount = 0; + + DataONEObject.prototype.fetch = function (options) { + fetchCallCount++; + options.success(); + }; + + dataPackage.fetchMemberModels.call(dataPackage, models, 0, 2, 5000, 3); + + setTimeout(function () { + expect(fetchCallCount).to.equal(2); + DataONEObject.prototype.fetch = originalFetch; + done(); + }, 100); + }); + + it("should retry fetching member models on failure", function (done) { + const models = [new DataONEObject(), new DataONEObject()]; + const originalFetch = DataONEObject.prototype.fetch; + let fetchCallCount = 0; + let maxRetries = 3; + + DataONEObject.prototype.fetch = function (options) { + fetchCallCount++; + options.error({ statusText: "Internal Server Error" }); + }; + + dataPackage.fetchMemberModels(models, 0, 2, 5000, maxRetries); + + setTimeout(function () { + console.log("[should retry fetching member models on failure] "+ fetchCallCount + " fetch calls"); + expect(fetchCallCount).to.equal(models.length * (maxRetries + 1)); // 2 models * 3 retries + DataONEObject.prototype.fetch = originalFetch; + done(); + }, 100); + }); + + it("should trigger complete event after fetching all models", function (done) { + const models = [new DataONEObject({identifier: "1"}), new DataONEObject({identifier: "2"})]; + const originalFetch = DataONEObject.prototype.fetch; + let fetchCallCount = 0; + let completeEventTriggered = false; + let maxRetries = 3; + + DataONEObject.prototype.fetch = function (options) { + console.log("[should trigger complete event after fetching all models] fetching model: " + this.get("identifier")); + fetchCallCount++; + options.success(); + }; + + dataPackage.triggerComplete = function () { + completeEventTriggered = true; + console.log("[should trigger complete event after fetching all models] complete event triggered"); + }; + + dataPackage.fetchMemberModels(models, 0, 2, 100, maxRetries); + + setTimeout(function () { + console.log("[should trigger complete event after fetching all models] "+ fetchCallCount + " fetch calls"); + console.log("[should trigger complete event after fetching all models] "+ completeEventTriggered); + expect(fetchCallCount).to.equal(models.length * (maxRetries + 1)); + expect(completeEventTriggered).to.be.true; + DataONEObject.prototype.fetch = originalFetch; + done(); + }, 1000); + }); + }); }); -}); +}); \ No newline at end of file diff --git a/test/js/specs/unit/views/DataItemView.spec.js b/test/js/specs/unit/views/DataItemView.spec.js new file mode 100644 index 000000000..ffc849032 --- /dev/null +++ b/test/js/specs/unit/views/DataItemView.spec.js @@ -0,0 +1,143 @@ +define([ + "jquery", + "underscore", + "backbone", + "models/DataONEObject", + "views/DataItemView", +], function ($, _, Backbone, DataONEObject, DataItemView) { + var expect = chai.expect; + + describe("DataItemView Test Suite", function () { + let dataItemView, model, collection; + + // Set up the test environment before each test + beforeEach(function () { + // Create a new DataONEObject model with a test identifier + model = new DataONEObject({ identifier: "test-id" }); + // Create a new Backbone collection + collection = new Backbone.Collection(); + // Initialize the DataItemView with the model and collection + dataItemView = new DataItemView({ + model: model, + collection: collection + }); + + // Stub the getParentScienceMetadata function to return a mock object + sinon.stub(dataItemView, "getParentScienceMetadata").returns({ + id: "mock-sci-meta-id" + }); + + // Stub the getParentDataPackage function to return a mock object with a spy on the add method + sinon.stub(dataItemView, "getParentDataPackage").returns({ + packageModel: { id: "mock-package-id" }, + add: sinon.spy() + }); + }); + + // Clean up the test environment after each test + afterEach(function () { + // Restore the stubbed methods to their original implementations + dataItemView.getParentScienceMetadata.restore(); + dataItemView.getParentDataPackage.restore(); + dataItemView.remove(); + }); + + describe("uploadFilesInBatch", function () { + it("should upload files in batches", function (done) { + // Create a list of DataONEObject models with initial upload status + const fileList = [ + new DataONEObject({ uploadFile: true, uploadStatus: "l" }), + new DataONEObject({ uploadFile: true, uploadStatus: "l" }), + new DataONEObject({ uploadFile: true, uploadStatus: "l" }) + ]; + + // Define the batch size for the upload + const batchSize = 2; + // Spy on the uploadFilesInBatch method to verify its call + const uploadSpy = sinon.spy(dataItemView, "uploadFilesInBatch"); + // Stub the save method to simulate setting the upload status to "p" + const saveStub = sinon.stub(DataONEObject.prototype, "save").callsFake(function () { + this.set("uploadStatus", "p"); + }); + // Stub the calculateChecksum method to simulate setting checksum attributes + const checksumStub = sinon.stub(DataONEObject.prototype, "calculateChecksum").callsFake(function () { + this.set("checksum", "fakeChecksum"); + this.set("checksumAlgorithm", "fakeAlgorithm"); + this.trigger("checksumCalculated", this.attributes); + }); + + // Call the method to be tested + dataItemView.uploadFilesInBatch(fileList, batchSize); + + // Simulate the completion of the upload by setting the upload status to "c" + fileList.forEach(function (file) { + file.set("uploadStatus", "c"); + }); + + // Use setTimeout to allow asynchronous operations to complete + setTimeout(function () { + // Log the call counts for debugging purposes + console.log("[should upload files in batches] uploadSpy.callCount: ", uploadSpy.callCount); + console.log("[should upload files in batches] checksumSpy.callCount: ", checksumStub.callCount); + + // Verify that the method was called once with the correct arguments + expect(uploadSpy.calledOnce).to.be.true; + expect(uploadSpy.calledWith(fileList, batchSize)).to.be.true; + // Verify that the calculateChecksum method was called the expected number of times + console.log("[should upload files in batches] fileList.length: ", fileList.length); + console.log("[should upload files in batches] saveSpy.callCount: ", saveStub.callCount); + expect(checksumStub.callCount).to.equal(fileList.length); + expect(saveStub.callCount).to.equal(fileList.length); + // Restore the spies and stubs + uploadSpy.restore(); + checksumStub.restore(); + saveStub.restore(); + // Indicate that the test is complete + done(); + }, 0); + }); + }); + + describe("addFiles", function () { + it("should add files to the collection", function (done) { + // Create a fake file object to simulate a file upload + const fakeFile = new Blob(["fake file content"], { type: "text/plain" }); + fakeFile.name = "fakeFile.txt"; + + // Create a mock event object with the necessary properties + const event = { + stopPropagation: sinon.spy(), + preventDefault: sinon.spy(), + target: { files: [fakeFile] }, + originalEvent: { dataTransfer: { files: [fakeFile] } }, + delegateTarget: { dataset: { id: "test-id" } } + }; + + // Stub the methods to simulate their behavior + const uploadStub = sinon.stub(dataItemView, "uploadFilesInBatch").returns(true); + const d1ObjectStub = sinon.stub(DataONEObject.prototype, "initialize").returns(true); + + // Call the method to be tested + dataItemView.addFiles.call(dataItemView, event); + + // Use setTimeout to allow asynchronous operations to complete + setTimeout(function () { + // Verify that the event methods were called + expect(event.stopPropagation.calledOnce).to.be.true; + expect(event.preventDefault.calledOnce).to.be.true; + // Verify that the DataONEObject initialize method was called + console.log("[should add files to the collection] d1ObjectStub.callCount: ", d1ObjectStub.callCount); + expect(d1ObjectStub.calledOnce).to.be.true; + // Verify that the uploadFilesInBatch method was called + console.log("[should add files to the collection] uploadStub.callCount: ", uploadStub.callCount); + expect(uploadStub.calledOnce).to.be.true; + // Restore the stubs + uploadStub.restore(); + d1ObjectStub.restore(); + // Indicate that the test is complete + done(); + }, 0); + }); + }); + }); +}); \ No newline at end of file From 1c98d96cf3f4ddd2f1943e9dea5ea043b8474d62 Mon Sep 17 00:00:00 2001 From: Val Hendrix Date: Tue, 7 Jan 2025 10:56:04 -0800 Subject: [PATCH 5/6] chore(format): Uses prettier to fix formatting issues --- src/js/collections/DataPackage.js | 181 ++++++----- src/js/views/DataItemView.js | 306 +++++++++--------- src/js/views/metadata/EML211EditorView.js | 31 +- .../unit/collections/DataPackage.spec.js | 33 +- test/js/specs/unit/views/DataItemView.spec.js | 68 ++-- 5 files changed, 356 insertions(+), 263 deletions(-) diff --git a/src/js/collections/DataPackage.js b/src/js/collections/DataPackage.js index c2a6eea5c..c3f07d765 100644 --- a/src/js/collections/DataPackage.js +++ b/src/js/collections/DataPackage.js @@ -442,7 +442,13 @@ define([ * @param {number} [maxRetries] - The maximum number of retries for each fetch request. * @since 0.0.0 */ - fetchMemberModels(models, index = 0, batchSize = 10, timeout = 5000, maxRetries = 3) { + fetchMemberModels( + models, + index = 0, + batchSize = 10, + timeout = 5000, + maxRetries = 3, + ) { // Update the number of file metadata items being loaded this.packageModel.set("numLoadingFileMetadata", models.length - index); @@ -454,93 +460,116 @@ define([ // If batchSize is 0, set it to the total number of models let batchSizeAdjust = batchSize; - if (batchSizeAdjust === 0 && index === 0) batchSizeAdjust = models.length; + if (batchSizeAdjust === 0 && index === 0) + batchSizeAdjust = models.length; const collection = this; // Slice the models array to get the current batch const batch = models.slice(index, index + batchSizeAdjust); // Create an array of promises for fetching each model in the batch - const fetchPromises = batch.map((memberModel) => new Promise((resolve, reject) => { - const attemptFetch = (retriesLeft) => { - // Create a promise for the fetch request - const fetchPromise = new Promise((fetchResolve, fetchReject) => { - memberModel.fetch({ - success: () => { - // Once the model is synced, handle the response - memberModel.once("sync", (oldModel) => { - const newModel = collection.getMember(oldModel); - - // If the type of the old model is different from the new model - if (oldModel.type !== newModel.type) { - if (newModel.type === "DataPackage") { - // If the new model is a DataPackage, replace the old model with the new one - oldModel.trigger("replace", newModel); - fetchResolve(); - } else { - // Otherwise, fetch the new model and replace the old model with the new one - newModel.set("synced", false); - newModel.fetch(); - newModel.once("sync", (fetchedModel) => { - fetchedModel.set("synced", true); - collection.remove(oldModel); - collection.add(fetchedModel); - oldModel.trigger("replace", newModel); - if (newModel.type === "EML") collection.trigger("add:EML"); + const fetchPromises = batch.map( + (memberModel) => + new Promise((resolve, reject) => { + const attemptFetch = (retriesLeft) => { + // Create a promise for the fetch request + const fetchPromise = new Promise( + (fetchResolve, fetchReject) => { + memberModel.fetch({ + success: () => { + // Once the model is synced, handle the response + memberModel.once("sync", (oldModel) => { + const newModel = collection.getMember(oldModel); + + // If the type of the old model is different from the new model + if (oldModel.type !== newModel.type) { + if (newModel.type === "DataPackage") { + // If the new model is a DataPackage, replace the old model with the new one + oldModel.trigger("replace", newModel); + fetchResolve(); + } else { + // Otherwise, fetch the new model and replace the old model with the new one + newModel.set("synced", false); + newModel.fetch(); + newModel.once("sync", (fetchedModel) => { + fetchedModel.set("synced", true); + collection.remove(oldModel); + collection.add(fetchedModel); + oldModel.trigger("replace", newModel); + if (newModel.type === "EML") + collection.trigger("add:EML"); + fetchResolve(); + }); + } + } else { + // If the type of the old model is the same as the new model, merge the new model into the collection + newModel.set("synced", true); + collection.add(newModel, { merge: true }); + if (newModel.type === "EML") + collection.trigger("add:EML"); fetchResolve(); - }); - } - } else { - // If the type of the old model is the same as the new model, merge the new model into the collection - newModel.set("synced", true); - collection.add(newModel, { merge: true }); - if (newModel.type === "EML") collection.trigger("add:EML"); - fetchResolve(); - } + } + }); + }, + error: (model, response) => + fetchReject(new Error(response.statusText)), }); }, - error: (model, response) => fetchReject(new Error(response.statusText)) - }); - }); - - // Create a promise for the timeout - const timeoutPromise = new Promise((__, timeoutReject) => { - setTimeout(() => timeoutReject(new Error("Fetch timed out")), timeout); - }); + ); - // Race the fetch promise against the timeout promise - Promise.race([fetchPromise, timeoutPromise]) - .then(resolve) - .catch((error) => { - if (retriesLeft > 0) { - // Retry the fetch if there are retries left - attemptFetch(retriesLeft - 1); - } else { - // Reject the promise if all retries are exhausted - reject(error); - } + // Create a promise for the timeout + const timeoutPromise = new Promise((__, timeoutReject) => { + setTimeout( + () => timeoutReject(new Error("Fetch timed out")), + timeout, + ); }); - }; - // Start the fetch attempt with the maximum number of retries - attemptFetch(maxRetries); - })); + // Race the fetch promise against the timeout promise + Promise.race([fetchPromise, timeoutPromise]) + .then(resolve) + .catch((error) => { + if (retriesLeft > 0) { + // Retry the fetch if there are retries left + attemptFetch(retriesLeft - 1); + } else { + // Reject the promise if all retries are exhausted + reject(error); + } + }); + }; + + // Start the fetch attempt with the maximum number of retries + attemptFetch(maxRetries); + }), + ); // Once all fetch promises are resolved, fetch the next batch - Promise.allSettled(fetchPromises).then((results) => { - const errors = results.filter(result => result.status === "rejected"); - if (errors.length > 0) { + Promise.allSettled(fetchPromises) + .then((results) => { + const errors = results.filter( + (result) => result.status === "rejected", + ); + if (errors.length > 0) { + /* eslint-disable */ + console.error("Error fetching member models:", errors); + /* eslint-enable */ + } + // Fetch the next batch of models + this.fetchMemberModels.call( + collection, + models, + index + batchSizeAdjust, + batchSizeAdjust, + timeout, + maxRetries, + ); + }) + .catch((error) => { /* eslint-disable */ - console.error("Error fetching member models:", errors); + console.error("Error fetching member models:", error); /* eslint-enable */ - } - // Fetch the next batch of models - this.fetchMemberModels.call(collection, models, index + batchSizeAdjust, batchSizeAdjust, timeout, maxRetries); - }).catch((error) => { - /* eslint-disable */ - console.error("Error fetching member models:", error); - /* eslint-enable */ - }); + }); }, /** @@ -822,7 +851,12 @@ define([ // Collection is set to false if (this.fetchModels !== false) { // Start fetching member models - this.fetchMemberModels.call(this, models, 0, MetacatUI.appModel.get("batchSizeFetch")); + this.fetchMemberModels.call( + this, + models, + 0, + MetacatUI.appModel.get("batchSizeFetch"), + ); } } catch (error) { // TODO: Handle the error @@ -3765,7 +3799,6 @@ define([ this.packageModel.updateSysMeta(); }, - /** * Tracks the upload status of DataONEObject models in this collection. If * they are `loading` into the DOM or `in progress` of an upload to the diff --git a/src/js/views/DataItemView.js b/src/js/views/DataItemView.js index 958a96373..13841ca0a 100644 --- a/src/js/views/DataItemView.js +++ b/src/js/views/DataItemView.js @@ -92,7 +92,7 @@ define([ * Initialize the object - post constructor * @param options */ - initialize (options) { + initialize(options) { if (typeof options === "undefined") var options = {}; this.model = options.model || new DataONEObject(); @@ -117,7 +117,7 @@ define([ * Renders a DataItemView for the given DataONEObject * @param {DataONEObject} model */ - render (model) { + render(model) { // Prevent duplicate listeners this.stopListening(); @@ -125,10 +125,10 @@ define([ // Set the data-id for identifying events to model ids this.$el.attr( "data-id", - `${this.itemPath ? this.itemPath : "" }/${ this.itemName}`, + `${this.itemPath ? this.itemPath : ""}/${this.itemName}`, ); this.$el.attr("data-parent", this.itemPath ? this.itemPath : ""); - this.$el.attr("data-category", `entities-${ this.itemName}`); + this.$el.attr("data-category", `entities-${this.itemName}`); var attributes = new Object(); attributes.fileType = undefined; @@ -167,7 +167,7 @@ define([ } else { // Set the data-id for identifying events to model ids this.$el.attr("data-id", this.model.get("id")); - this.$el.attr("data-category", `entities-${ this.model.get("id")}`); + this.$el.attr("data-category", `entities-${this.model.get("id")}`); // Destroy the old tooltip this.$(".status .icon, .status .progress") @@ -221,7 +221,7 @@ define([ if (this.model.type != "EML") { // Get the parent EML model if (this.parentEML) { - var {parentEML} = this; + var { parentEML } = this; } else { var parentEML = MetacatUI.rootDataPackage.where({ id: Array.isArray(this.model.get("isDocumentedBy")) @@ -341,23 +341,21 @@ define([ // Create tooltips based on the upload status const uploadStatus = this.model.get("uploadStatus"); - let errorMessage = this.model.get("errorMessage"); + let errorMessage = this.model.get("errorMessage"); // Use a friendlier message for 401 errors (the one returned is a little hard to understand) if (this.model.get("sysMetaErrorCode") == 401) { // If the user at least has write permission, they cannot update the system metadata only, so show this message /** @todo Do an object update when someone has write permission but not changePermission and is trying to change the system metadata (but not the access policy) */ if (accessPolicy && accessPolicy.isAuthorized("write")) { - errorMessage = - `The owner of this data file has not given you permission to rename it or change the ${ - MetacatUI.appModel.get("accessPolicyName") - }.`; + errorMessage = `The owner of this data file has not given you permission to rename it or change the ${MetacatUI.appModel.get( + "accessPolicyName", + )}.`; // Otherwise, assume they only have read access } else { - errorMessage = - `The owner of this data file has not given you permission to edit this data file or change the ${ - MetacatUI.appModel.get("accessPolicyName") - }.`; + errorMessage = `The owner of this data file has not given you permission to edit this data file or change the ${MetacatUI.appModel.get( + "accessPolicyName", + )}.`; } } @@ -369,12 +367,7 @@ define([ placement: "top", trigger: "hover", html: true, - title: - `
Issue saving:
${ - errorMessage - }
`, + title: `
Issue saving:
${errorMessage}
`, container: "body", }); @@ -428,20 +421,19 @@ define([ this.$el.addClass("loading"); } else if (uploadStatus == "p") { - var {model} = this; + var { model } = this; this.$(".status .progress").tooltip({ placement: "top", trigger: "hover", html: true, - title () { + title() { if (model.get("numSaveAttempts") > 0) { - return ( - `
Something went wrong during upload.
Trying again... (attempt ${ - model.get("numSaveAttempts") + 1 - } of 3)
` - ); - } if (model.get("uploadProgress")) { + return `
Something went wrong during upload.
Trying again... (attempt ${ + model.get("numSaveAttempts") + 1 + } of 3)
`; + } + if (model.get("uploadProgress")) { var percentDone = model.get("uploadProgress").toString(); if (percentDone.indexOf(".") > -1) percentDone = percentDone.substring( @@ -450,11 +442,7 @@ define([ ); } else var percentDone = "0"; - return ( - `
Uploading: ${ - percentDone - }%
` - ); + return `
Uploading: ${percentDone}%
`; }, container: "body", }); @@ -500,7 +488,7 @@ define([ this.model.getFormat() == "metadata" || this.model.get("id") == this.currentlyViewing ) { - attributes.title = `Metadata: ${ this.model.get("fileName")}`; + attributes.title = `Metadata: ${this.model.get("fileName")}`; attributes.icon = "icon-file-text"; attributes.metricIcon = "icon-eye-open"; this.isMetadata = true; @@ -511,9 +499,10 @@ define([ attributes.title || attributes.fileName || attributes.id; attributes.objectTitle = objectTitleTooltip.length > 150 - ? `${objectTitleTooltip.slice(0, 75) - }...${ - objectTitleTooltip.slice( + ? `${objectTitleTooltip.slice( + 0, + 75, + )}...${objectTitleTooltip.slice( objectTitleTooltip.length - 75, objectTitleTooltip.length, )}` @@ -535,11 +524,14 @@ define([ attributes.id = this.model.get("id"); attributes.memberRowMetrics = null; let metricToolTip = null; - var view = this; + var view = this; // Insert metrics for this item, // if the model has already been fethced. - if (this.metricsModel != null && this.metricsModel.get("views") !== null) { + if ( + this.metricsModel != null && + this.metricsModel.get("views") !== null + ) { metricToolTip = this.getMemberRowMetrics(view.id); attributes.memberRowMetrics = metricToolTip.split(" ")[0]; } else { @@ -548,7 +540,7 @@ define([ this.listenTo(this.metricsModel, "sync", function () { metricToolTip = this.getMemberRowMetrics(view.id); const readsCell = this.$( - `.metrics-count.downloads[data-id="${ view.id }"]`, + `.metrics-count.downloads[data-id="${view.id}"]`, ); metricToolTip = view.getMemberRowMetrics(view.id); if (typeof metricToolTip !== "undefined" && metricToolTip) @@ -613,12 +605,9 @@ define([ this.downloadButtonView.render(); const id = this.model.get("id"); - const infoLink = - `${MetacatUI.root - }/view/${ - encodeURIComponent(this.currentlyViewing) - }#${ - encodeURIComponent(id)}`; + const infoLink = `${MetacatUI.root}/view/${encodeURIComponent( + this.currentlyViewing, + )}#${encodeURIComponent(id)}`; attributes.moreInfoLink = infoLink; attributes.insertInfoIcon = this.insertInfoIcon; @@ -656,7 +645,7 @@ define([ * Renders a button that opens the AccessPolicyView for editing permissions on this data item * @since 2.15.0 */ - renderShareControl () { + renderShareControl() { // Get the Share button element const shareButton = this.$(".sharing button"); @@ -691,7 +680,7 @@ define([ }, /** Close the view and remove it from the DOM */ - onClose () { + onClose() { this.remove(); // remove for the DOM, stop listening this.off(); // remove callbacks, prevent zombies }, @@ -700,7 +689,7 @@ define([ Generate a unique id for each data item in the table TODO: This could be replaced with the DataONE identifier */ - generateId () { + generateId() { let idStr = ""; // the id to return const length = 30; // the length of the generated string const chars = @@ -719,7 +708,7 @@ define([ * Update the folder name based on the scimeta title * @param e The event triggering this method */ - updateName (e) { + updateName(e) { const enteredText = this.cleanInput($(e.target).text().trim()); // Set the title if this item is metadata or set the file name @@ -767,7 +756,7 @@ define([ Multiple files are allowed using the shift and or option/alt key @param {Event} event */ - handleAddFiles (event) { + handleAddFiles(event) { event.stopPropagation(); const fileUploadElement = this.$(".file-upload"); @@ -814,19 +803,37 @@ define([ // Create a new Promise to handle the file upload new Promise((resolve, reject) => { // If the file needs to be uploaded and its checksum is not calculated - if (dataONEObject.get("uploadFile") && !dataONEObject.get("checksum")) { + if ( + dataONEObject.get("uploadFile") && + !dataONEObject.get("checksum") + ) { // Stop listening to previous checksumCalculated events - dataONEObject.stopListening(dataONEObject, "checksumCalculated"); + dataONEObject.stopListening( + dataONEObject, + "checksumCalculated", + ); // Listen to the checksumCalculated event to start the upload - dataONEObject.listenToOnce(dataONEObject, "checksumCalculated", () => { - dataONEObject.save(); // Save the file - // Listen to changes in the uploadStatus to resolve the Promise - dataONEObject.listenTo(dataONEObject, "change:uploadStatus", () => { - if (dataONEObject.get("uploadStatus") !== "p" && dataONEObject.get("uploadStatus") !== "q" && dataONEObject.get("uploadStatus") !== "l") { - resolve(); // Resolve the Promise when the upload is complete - } - }); - }); + dataONEObject.listenToOnce( + dataONEObject, + "checksumCalculated", + () => { + dataONEObject.save(); // Save the file + // Listen to changes in the uploadStatus to resolve the Promise + dataONEObject.listenTo( + dataONEObject, + "change:uploadStatus", + () => { + if ( + dataONEObject.get("uploadStatus") !== "p" && + dataONEObject.get("uploadStatus") !== "q" && + dataONEObject.get("uploadStatus") !== "l" + ) { + resolve(); // Resolve the Promise when the upload is complete + } + }, + ); + }, + ); try { dataONEObject.calculateChecksum(); // Calculate the checksum } catch (exception) { @@ -836,15 +843,15 @@ define([ resolve(); // Resolve the Promise if the file does not need to be uploaded } }) - .then(() => { - activeUploads--; // Decrement the active uploads counter - uploadNextFile(); // Start the next file upload - }) - .catch((error) => { - console.error("Error uploading file:", error); - activeUploads--; // Decrement the active uploads counter - uploadNextFile(); // Start the next file upload - }); + .then(() => { + activeUploads--; // Decrement the active uploads counter + uploadNextFile(); // Start the next file upload + }) + .catch((error) => { + console.error("Error uploading file:", error); + activeUploads--; // Decrement the active uploads counter + uploadNextFile(); // Start the next file upload + }); uploadNextFile(); // Start the next file upload } @@ -861,10 +868,10 @@ define([ add the files to the collection @param {Event} event */ - addFiles (event) { + addFiles(event) { let fileList; // The list of chosen files - let parentDataPackage; // The id of the first resource of this row's scimeta - const self = this; // A reference to this view + let parentDataPackage; // The id of the first resource of this row's scimeta + const self = this; // A reference to this view event.stopPropagation(); event.preventDefault(); @@ -874,8 +881,8 @@ define([ // handle file picker files } else if (event.target) { - fileList = event.target.files; - } + fileList = event.target.files; + } this.$el.removeClass("droppable"); // Find the correct collection to add to. Use JQuery's delegateTarget @@ -889,7 +896,7 @@ define([ fileList, function (file) { let uploadStatus = "l"; - let errorMessage = ""; + let errorMessage = ""; if (file.size == 0) { uploadStatus = "e"; @@ -921,13 +928,16 @@ define([ queueFilesPromise.then(() => { // Call the batch upload method - this.uploadFilesInBatch(this.collection.models, MetacatUI.appModel.get('batchSizeUpload')); + this.uploadFilesInBatch( + this.collection.models, + MetacatUI.appModel.get("batchSizeUpload"), + ); }); } }, /** Show the drop zone for this row in the table */ - showDropzone () { + showDropzone() { if (this.model.get("type") !== "Metadata") return; this.$el.addClass("droppable"); }, @@ -936,7 +946,7 @@ define([ * Hide the drop zone for this row in the table * @param event */ - hideDropzone (event) { + hideDropzone(event) { if (this.model.get("type") !== "Metadata") return; this.$el.removeClass("droppable"); }, @@ -949,7 +959,7 @@ define([ * class .replaceFile. See this View's events map. * @param {MouseEvent} event Browser Click event */ - handleReplace (event) { + handleReplace(event) { event.stopPropagation(); // Stop immediately if we know the user doesn't have privs @@ -989,7 +999,7 @@ define([ * state. * @param {Event} event */ - replaceFile (event) { + replaceFile(event) { event.stopPropagation(); event.preventDefault(); @@ -1014,8 +1024,8 @@ define([ const oldUploadStatus = this.model.get("uploadStatus"); const file = fileList[0]; - let uploadStatus = "q"; - let errorMessage = ""; + let uploadStatus = "q"; + let errorMessage = ""; if (file.size == 0) { uploadStatus = "e"; @@ -1127,19 +1137,17 @@ define([ this.render(); } - - }, /** Handle remove events for this row in the data package table @param {Event} event */ - handleRemove (event) { + handleRemove(event) { let eventId; // The id of the row of this event - const removalIds = []; // The list of target ids to remove - let dataONEObject; // The model represented by this row - let documents; // The list of ids documented by this row (if meta) + const removalIds = []; // The list of target ids to remove + let dataONEObject; // The model represented by this row + let documents; // The list of ids documented by this row (if meta) event.stopPropagation(); event.preventDefault(); @@ -1216,11 +1224,11 @@ define([ * data or metadata row of the UI event * @param {Event} event */ - getParentScienceMetadata (event) { + getParentScienceMetadata(event) { let parentMetadata; // The parent metadata array in the collection - let eventModels; // The models associated with the event's table row - let eventModel; // The model associated with the event's table row - let parentSciMeta; // The parent science metadata for the event model + let eventModels; // The models associated with the event's table row + let eventModel; // The model associated with the event's table row + let parentSciMeta; // The parent science metadata for the event model if (typeof event.delegateTarget.dataset.id !== "undefined") { eventModels = MetacatUI.rootDataPackage.where({ @@ -1237,25 +1245,23 @@ define([ if (eventModel.get && eventModel.get("type") === "Metadata") { return eventModel; } - // It's data, get the parent scimeta - parentMetadata = MetacatUI.rootDataPackage.where({ - id: Array.isArray(eventModel.get("isDocumentedBy")) - ? eventModel.get("isDocumentedBy")[0] - : null, - }); - - if (parentMetadata.length > 0) { - parentSciMeta = parentMetadata[0]; - return parentSciMeta; - } - // If there is only one metadata model in the root data package, then use that metadata model - const metadataModels = MetacatUI.rootDataPackage.where({ - type: "Metadata", - }); - - if (metadataModels.length == 1) return metadataModels[0]; + // It's data, get the parent scimeta + parentMetadata = MetacatUI.rootDataPackage.where({ + id: Array.isArray(eventModel.get("isDocumentedBy")) + ? eventModel.get("isDocumentedBy")[0] + : null, + }); + if (parentMetadata.length > 0) { + parentSciMeta = parentMetadata[0]; + return parentSciMeta; + } + // If there is only one metadata model in the root data package, then use that metadata model + const metadataModels = MetacatUI.rootDataPackage.where({ + type: "Metadata", + }); + if (metadataModels.length == 1) return metadataModels[0]; } }, @@ -1264,8 +1270,10 @@ define([ * data or metadata row of the UI event * @param {Event} event */ - getParentDataPackage (event) { - let parentSciMeta; let parenResourceMaps; let parentResourceMapId; + getParentDataPackage(event) { + let parentSciMeta; + let parenResourceMaps; + let parentResourceMapId; if (typeof event.delegateTarget.dataset.id !== "undefined") { parentSciMeta = this.getParentScienceMetadata(event); @@ -1297,10 +1305,9 @@ define([ // A nested package } - return MetacatUI.rootDataPackage.where({ - id: parentResourceMapId, - })[0]; - + return MetacatUI.rootDataPackage.where({ + id: parentResourceMapId, + })[0]; } }, @@ -1309,7 +1316,7 @@ define([ * @param {string} input The string to clean * @returns {string} */ - cleanInput (input) { + cleanInput(input) { // 1. remove line breaks / Mso classes const stringStripper = /(\n|\r| class=(")?Mso[a-zA-Z]+(")?)/g; let output = input.replace(stringStripper, " "); @@ -1337,7 +1344,7 @@ define([ for (var i = 0; i < badTags.length; i++) { tagStripper = new RegExp( - `<${ badTags[i] }.*?${ badTags[i] }(.*?)>`, + `<${badTags[i]}.*?${badTags[i]}(.*?)>`, "gi", ); output = output.replace(tagStripper, ""); @@ -1347,7 +1354,7 @@ define([ const badAttributes = ["style", "start"]; for (var i = 0; i < badAttributes.length; i++) { const attributeStripper = new RegExp( - ` ${ badAttributes[i] }="(.*?)"`, + ` ${badAttributes[i]}="(.*?)"`, "gi", ); output = output.replace(attributeStripper, ""); @@ -1361,7 +1368,7 @@ define([ /** * Style this table row to indicate it will be removed */ - previewRemove () { + previewRemove() { this.$el.toggleClass("remove-preview"); }, @@ -1370,7 +1377,7 @@ define([ * an 'empty' class, and remove it when the user focuses back out. * @param {Event} e */ - emptyName (e) { + emptyName(e) { const editableCell = this.$(".canRename [contenteditable]"); editableCell.tooltip("hide"); @@ -1393,7 +1400,7 @@ define([ * Changes the access policy of a data object based on user input. * @param {Event} e - The event that triggered this function as a callback */ - changeAccessPolicy (e) { + changeAccessPolicy(e) { if (typeof e === "undefined" || !e) return; const accessPolicy = this.model.get("accessPolicy"); @@ -1412,14 +1419,14 @@ define([ this.model.get("accessPolicy").makePrivate(); } } else if (accessPolicy) { - // Make the access policy public - accessPolicy.makePublic(); - } else { - // Create an access policy from the default settings - this.model.createAccessPolicy(); - // Make the access policy public - this.model.get("accessPolicy").makePublic(); - } + // Make the access policy public + accessPolicy.makePublic(); + } else { + // Create an access policy from the default settings + this.model.createAccessPolicy(); + // Make the access policy public + this.model.get("accessPolicy").makePublic(); + } }, /** @@ -1427,20 +1434,20 @@ define([ * @param {string} attr The modal attribute that has been validated * @param {string} errorMsg The validation error message to display */ - showValidation (attr, errorMsg) { + showValidation(attr, errorMsg) { // Find the element that is required - const requiredEl = this.$(`[data-category='${ attr }']`).addClass( + const requiredEl = this.$(`[data-category='${attr}']`).addClass( "error", ); // When it is updated, remove the error styling - this.listenToOnce(this.model, `change:${ attr}`, this.hideRequired); + this.listenToOnce(this.model, `change:${attr}`, this.hideRequired); }, /** * Hides the 'required' styling from this view */ - hideRequired () { + hideRequired() { // Remove the error styling this.$("[contenteditable].error").removeClass("error"); }, @@ -1448,7 +1455,7 @@ define([ /** * Show the data item as saving */ - showSaving () { + showSaving() { this.$(".controls button").prop("disabled", true); if (this.model.get("type") != "Metadata") @@ -1462,7 +1469,7 @@ define([ /** * Hides the styles applied in {@link DataItemView#showSaving} */ - hideSaving () { + hideSaving() { this.$(".controls button").prop("disabled", false); this.$(".disable-layer").remove(); @@ -1472,7 +1479,7 @@ define([ this.$el.removeClass("error-saving"); }, - toggleSaving () { + toggleSaving() { if ( this.model.get("uploadStatus") == "p" || this.model.get("uploadStatus") == "l" || @@ -1490,13 +1497,13 @@ define([ /** * Shows the current progress of the file upload */ - showUploadProgress () { + showUploadProgress() { if (this.model.get("numSaveAttempts") > 0) { this.$(".progress .bar").css("width", "100%"); } else { this.$(".progress .bar").css( "width", - `${this.model.get("uploadProgress") }%`, + `${this.model.get("uploadProgress")}%`, ); } }, @@ -1514,13 +1521,13 @@ define([ * @returns {boolean} Whether the item can be shared * @since 2.15.0 */ - canShareItem () { + canShareItem() { if (this.parentEditorView) { if (this.parentEditorView.isAccessPolicyEditEnabled()) { if (this.model.type === "EML") { // Check whether we can share the resource map const pkgModel = MetacatUI.rootDataPackage.packageModel; - const pkgAccessPolicy = pkgModel.get("accessPolicy"); + const pkgAccessPolicy = pkgModel.get("accessPolicy"); const canShareResourceMap = pkgModel.isNew() || @@ -1537,22 +1544,21 @@ define([ // Only return true if we can share both return canShareMetadata && canShareResourceMap; } - return ( - this.model.get("accessPolicy") && - this.model.get("accessPolicy").isAuthorized("changePermission") - ); - + return ( + this.model.get("accessPolicy") && + this.model.get("accessPolicy").isAuthorized("changePermission") + ); } } }, - downloadFile (e) { + downloadFile(e) { this.downloadButtonView.download(e); }, // Member row metrics for the package table // Retrieving information from the Metrics Model's result details - getMemberRowMetrics (id) { + getMemberRowMetrics(id) { if (typeof this.metricsModel !== "undefined") { const metricsResultDetails = this.metricsModel.get("resultDetails"); diff --git a/src/js/views/metadata/EML211EditorView.js b/src/js/views/metadata/EML211EditorView.js index 5a64499a3..7e8eca478 100644 --- a/src/js/views/metadata/EML211EditorView.js +++ b/src/js/views/metadata/EML211EditorView.js @@ -552,7 +552,10 @@ define([ "change:numLoadingFiles", this.toggleEnableControls, ); - this.stopListening(MetacatUI.rootDataPackage.packageModel, "change:numLoadingFileMetadata"); + this.stopListening( + MetacatUI.rootDataPackage.packageModel, + "change:numLoadingFileMetadata", + ); this.listenTo( MetacatUI.rootDataPackage.packageModel, "change:numLoadingFileMetadata", @@ -1198,19 +1201,23 @@ define([ toggleEnableControls() { if (MetacatUI.rootDataPackage.packageModel.get("isLoadingFiles")) { const noun = - MetacatUI.rootDataPackage.packageModel.get("numLoadingFiles") > 1 - ? " files" - : " file"; + MetacatUI.rootDataPackage.packageModel.get("numLoadingFiles") > 1 + ? " files" + : " file"; + this.disableControls( + `Waiting for ${MetacatUI.rootDataPackage.packageModel.get( + "numLoadingFiles", + )}${noun} to upload...`, + ); + } else if ( + MetacatUI.rootDataPackage.packageModel.get("numLoadingFileMetadata") > + 0 + ) { this.disableControls( - `Waiting for ${ - MetacatUI.rootDataPackage.packageModel.get("numLoadingFiles") - }${noun - } to upload...`, + `Waiting for ${MetacatUI.rootDataPackage.packageModel.get( + "numLoadingFileMetadata", + )} file metadata to load...`, ); - } else if (MetacatUI.rootDataPackage.packageModel.get("numLoadingFileMetadata") >0) { - this.disableControls(`Waiting for ${ - MetacatUI.rootDataPackage.packageModel.get("numLoadingFileMetadata") - } file metadata to load...`); } else { this.enableControls(); } diff --git a/test/js/specs/unit/collections/DataPackage.spec.js b/test/js/specs/unit/collections/DataPackage.spec.js index 7d1aaf833..9ee585c98 100644 --- a/test/js/specs/unit/collections/DataPackage.spec.js +++ b/test/js/specs/unit/collections/DataPackage.spec.js @@ -77,7 +77,11 @@ define([ dataPackage.fetchMemberModels(models, 0, 2, 5000, maxRetries); setTimeout(function () { - console.log("[should retry fetching member models on failure] "+ fetchCallCount + " fetch calls"); + console.log( + "[should retry fetching member models on failure] " + + fetchCallCount + + " fetch calls", + ); expect(fetchCallCount).to.equal(models.length * (maxRetries + 1)); // 2 models * 3 retries DataONEObject.prototype.fetch = originalFetch; done(); @@ -85,28 +89,43 @@ define([ }); it("should trigger complete event after fetching all models", function (done) { - const models = [new DataONEObject({identifier: "1"}), new DataONEObject({identifier: "2"})]; + const models = [ + new DataONEObject({ identifier: "1" }), + new DataONEObject({ identifier: "2" }), + ]; const originalFetch = DataONEObject.prototype.fetch; let fetchCallCount = 0; let completeEventTriggered = false; let maxRetries = 3; DataONEObject.prototype.fetch = function (options) { - console.log("[should trigger complete event after fetching all models] fetching model: " + this.get("identifier")); + console.log( + "[should trigger complete event after fetching all models] fetching model: " + + this.get("identifier"), + ); fetchCallCount++; options.success(); }; dataPackage.triggerComplete = function () { completeEventTriggered = true; - console.log("[should trigger complete event after fetching all models] complete event triggered"); + console.log( + "[should trigger complete event after fetching all models] complete event triggered", + ); }; dataPackage.fetchMemberModels(models, 0, 2, 100, maxRetries); setTimeout(function () { - console.log("[should trigger complete event after fetching all models] "+ fetchCallCount + " fetch calls"); - console.log("[should trigger complete event after fetching all models] "+ completeEventTriggered); + console.log( + "[should trigger complete event after fetching all models] " + + fetchCallCount + + " fetch calls", + ); + console.log( + "[should trigger complete event after fetching all models] " + + completeEventTriggered, + ); expect(fetchCallCount).to.equal(models.length * (maxRetries + 1)); expect(completeEventTriggered).to.be.true; DataONEObject.prototype.fetch = originalFetch; @@ -115,4 +134,4 @@ define([ }); }); }); -}); \ No newline at end of file +}); diff --git a/test/js/specs/unit/views/DataItemView.spec.js b/test/js/specs/unit/views/DataItemView.spec.js index ffc849032..67bc6dfc0 100644 --- a/test/js/specs/unit/views/DataItemView.spec.js +++ b/test/js/specs/unit/views/DataItemView.spec.js @@ -19,18 +19,18 @@ define([ // Initialize the DataItemView with the model and collection dataItemView = new DataItemView({ model: model, - collection: collection + collection: collection, }); // Stub the getParentScienceMetadata function to return a mock object sinon.stub(dataItemView, "getParentScienceMetadata").returns({ - id: "mock-sci-meta-id" + id: "mock-sci-meta-id", }); // Stub the getParentDataPackage function to return a mock object with a spy on the add method sinon.stub(dataItemView, "getParentDataPackage").returns({ packageModel: { id: "mock-package-id" }, - add: sinon.spy() + add: sinon.spy(), }); }); @@ -48,7 +48,7 @@ define([ const fileList = [ new DataONEObject({ uploadFile: true, uploadStatus: "l" }), new DataONEObject({ uploadFile: true, uploadStatus: "l" }), - new DataONEObject({ uploadFile: true, uploadStatus: "l" }) + new DataONEObject({ uploadFile: true, uploadStatus: "l" }), ]; // Define the batch size for the upload @@ -56,36 +56,52 @@ define([ // Spy on the uploadFilesInBatch method to verify its call const uploadSpy = sinon.spy(dataItemView, "uploadFilesInBatch"); // Stub the save method to simulate setting the upload status to "p" - const saveStub = sinon.stub(DataONEObject.prototype, "save").callsFake(function () { + const saveStub = sinon + .stub(DataONEObject.prototype, "save") + .callsFake(function () { this.set("uploadStatus", "p"); - }); + }); // Stub the calculateChecksum method to simulate setting checksum attributes - const checksumStub = sinon.stub(DataONEObject.prototype, "calculateChecksum").callsFake(function () { + const checksumStub = sinon + .stub(DataONEObject.prototype, "calculateChecksum") + .callsFake(function () { this.set("checksum", "fakeChecksum"); this.set("checksumAlgorithm", "fakeAlgorithm"); this.trigger("checksumCalculated", this.attributes); - }); + }); // Call the method to be tested dataItemView.uploadFilesInBatch(fileList, batchSize); // Simulate the completion of the upload by setting the upload status to "c" fileList.forEach(function (file) { - file.set("uploadStatus", "c"); + file.set("uploadStatus", "c"); }); // Use setTimeout to allow asynchronous operations to complete setTimeout(function () { // Log the call counts for debugging purposes - console.log("[should upload files in batches] uploadSpy.callCount: ", uploadSpy.callCount); - console.log("[should upload files in batches] checksumSpy.callCount: ", checksumStub.callCount); + console.log( + "[should upload files in batches] uploadSpy.callCount: ", + uploadSpy.callCount, + ); + console.log( + "[should upload files in batches] checksumSpy.callCount: ", + checksumStub.callCount, + ); // Verify that the method was called once with the correct arguments expect(uploadSpy.calledOnce).to.be.true; expect(uploadSpy.calledWith(fileList, batchSize)).to.be.true; // Verify that the calculateChecksum method was called the expected number of times - console.log("[should upload files in batches] fileList.length: ", fileList.length); - console.log("[should upload files in batches] saveSpy.callCount: ", saveStub.callCount); + console.log( + "[should upload files in batches] fileList.length: ", + fileList.length, + ); + console.log( + "[should upload files in batches] saveSpy.callCount: ", + saveStub.callCount, + ); expect(checksumStub.callCount).to.equal(fileList.length); expect(saveStub.callCount).to.equal(fileList.length); // Restore the spies and stubs @@ -101,7 +117,9 @@ define([ describe("addFiles", function () { it("should add files to the collection", function (done) { // Create a fake file object to simulate a file upload - const fakeFile = new Blob(["fake file content"], { type: "text/plain" }); + const fakeFile = new Blob(["fake file content"], { + type: "text/plain", + }); fakeFile.name = "fakeFile.txt"; // Create a mock event object with the necessary properties @@ -110,12 +128,16 @@ define([ preventDefault: sinon.spy(), target: { files: [fakeFile] }, originalEvent: { dataTransfer: { files: [fakeFile] } }, - delegateTarget: { dataset: { id: "test-id" } } + delegateTarget: { dataset: { id: "test-id" } }, }; // Stub the methods to simulate their behavior - const uploadStub = sinon.stub(dataItemView, "uploadFilesInBatch").returns(true); - const d1ObjectStub = sinon.stub(DataONEObject.prototype, "initialize").returns(true); + const uploadStub = sinon + .stub(dataItemView, "uploadFilesInBatch") + .returns(true); + const d1ObjectStub = sinon + .stub(DataONEObject.prototype, "initialize") + .returns(true); // Call the method to be tested dataItemView.addFiles.call(dataItemView, event); @@ -126,10 +148,16 @@ define([ expect(event.stopPropagation.calledOnce).to.be.true; expect(event.preventDefault.calledOnce).to.be.true; // Verify that the DataONEObject initialize method was called - console.log("[should add files to the collection] d1ObjectStub.callCount: ", d1ObjectStub.callCount); + console.log( + "[should add files to the collection] d1ObjectStub.callCount: ", + d1ObjectStub.callCount, + ); expect(d1ObjectStub.calledOnce).to.be.true; // Verify that the uploadFilesInBatch method was called - console.log("[should add files to the collection] uploadStub.callCount: ", uploadStub.callCount); + console.log( + "[should add files to the collection] uploadStub.callCount: ", + uploadStub.callCount, + ); expect(uploadStub.calledOnce).to.be.true; // Restore the stubs uploadStub.restore(); @@ -140,4 +168,4 @@ define([ }); }); }); -}); \ No newline at end of file +}); From adf9e6489a1162aef97e86dab6c634f7d9a68d94 Mon Sep 17 00:00:00 2001 From: Val Hendrix Date: Tue, 7 Jan 2025 11:35:26 -0800 Subject: [PATCH 6/6] chore(test): adds --no-sandbox to testing to prevent gh hang This follows the suggestion as seen in the error message on the ghaction: Run npm test > metacatui@2.31.0 test > node test/server.js Failed to launch the browser process! [2359:2359:0107/175821.608019:FATAL:zygote_host_impl_linux.cc(126)] No usable sandbox! Update your kernel or see https://chromium.googlesource.com/chromium/src/+/main/docs/linux/suid_sandbox_development.md for more information on developing with the SUID sandbox. If you want to live dangerously and need an immediate workaround, you can try using --no-sandbox. --- test/server.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/server.js b/test/server.js index 082def5bc..04e036d82 100644 --- a/test/server.js +++ b/test/server.js @@ -43,7 +43,10 @@ const server = app.listen(port); url = "http://localhost:" + port + url; async function runTests(url) { - const browser = await puppeteer.launch({ headless: true }); + const browser = await puppeteer.launch({ + headless: true, + args: ["--no-sandbox", "--disable-setuid-sandbox"], + }); const page = await browser.newPage(); await page.goto(url, { waitUntil: "networkidle0" }); const html = await page.content(); // serialized HTML of page DOM.