diff --git a/docs/screenshots/views/CanonicalDatasetHandlerViewView.png b/docs/screenshots/views/CanonicalDatasetHandlerViewView.png new file mode 100644 index 000000000..f89e11872 Binary files /dev/null and b/docs/screenshots/views/CanonicalDatasetHandlerViewView.png differ diff --git a/docs/screenshots/views/metadata/CanonicalDatasetHandlerViewView.png b/docs/screenshots/views/metadata/CanonicalDatasetHandlerViewView.png new file mode 100644 index 000000000..2ca4f06a6 Binary files /dev/null and b/docs/screenshots/views/metadata/CanonicalDatasetHandlerViewView.png differ diff --git a/src/css/metacatui-common.css b/src/css/metacatui-common.css index 30cd653db..08ab93b21 100644 --- a/src/css/metacatui-common.css +++ b/src/css/metacatui-common.css @@ -74,6 +74,9 @@ a:hover { .icon.warning { color: #ffbc00; } +.icon.info { + color: #3a87ad; +} .list-group-item.success { background-color: #dff0d8; } @@ -1963,11 +1966,10 @@ div.analyze.dropdown.open button.dropdown.btn.btn-secondary.dropdown-toggle { } .controls-container .info-icons .icon-stack .icon-stack-top { color: #fff; - font-size: 0.75em; - margin-top: -15px; + font-size: 0.7em; } .controls-container .info-icons .icon-stack .icon-stack-base { - font-size: 1.25em; + font-size: 1.5em; } .metadata-controls-container { position: relative; diff --git a/src/js/models/CitationModel.js b/src/js/models/CitationModel.js index 4c044d674..863ab5058 100644 --- a/src/js/models/CitationModel.js +++ b/src/js/models/CitationModel.js @@ -1,11 +1,6 @@ "use strict"; -define(["jquery", "underscore", "backbone", "collections/Citations"], ( - $, - _, - Backbone, - Citations, -) => { +define(["underscore", "backbone"], (_, Backbone) => { /** * @class CitationModel * @classdesc A Citation Model represents a single Citation Object returned by @@ -52,6 +47,8 @@ define(["jquery", "underscore", "backbone", "collections/Citations"], ( * published * @property {number|string} volume - The volume of the journal where the * document was published + * @property {number|string} issue - The issue of the journal where the + * document was published * @property {number} page - The page of the journal where the document was * published * @property {Citations} citationMetadata - When this Citation Model refers @@ -91,6 +88,7 @@ define(["jquery", "underscore", "backbone", "collections/Citations"], ( publisher: null, journal: null, volume: null, + issue: null, page: null, citationMetadata: null, sourceModel: null, @@ -118,6 +116,8 @@ define(["jquery", "underscore", "backbone", "collections/Citations"], ( pid: this.getPidFromSourceModel, seriesId: this.getSeriesIdFromSourceModel, originArray: this.getOriginArrayFromSourceModel, + volume: this.getVolumeFromSourceModel, + issue: this.getIssueFromSourceModel, view_url: this.getViewUrlFromSourceModel, }; }, @@ -412,10 +412,15 @@ define(["jquery", "underscore", "backbone", "collections/Citations"], ( */ getYearFromSourceModel(sourceModel) { try { - const year = + let year = this.yearFromDate(sourceModel.get("pubDate")) || this.yearFromDate(sourceModel.get("dateUploaded")) || this.yearFromDate(sourceModel.get("datePublished")); + // for cross ref + const created = sourceModel.get("created"); + if (!year && created) { + year = created?.["date-parts"]?.[0]?.[0]; + } return year; } catch (error) { console.log( @@ -476,6 +481,12 @@ define(["jquery", "underscore", "backbone", "collections/Citations"], ( getJournalFromSourceModel(sourceModel) { try { let journal = null; + journal = sourceModel.get("journal"); + + // cross ref + journal = sourceModel.get("container-title")?.[0]; + if (journal) return journal; + const datasource = sourceModel.get("datasource"); const mn = MetacatUI.nodeModel.getMember(datasource); const currentMN = MetacatUI.nodeModel.get("currentMemberNode"); @@ -519,6 +530,8 @@ define(["jquery", "underscore", "backbone", "collections/Citations"], ( sourceModel.get("creator") || // If it's a science metadata model or solr results, use origin sourceModel.get("origin") || + // If it's a cross ref model, use author + sourceModel.get("author") || ""; // otherwise, this is probably a base D1 object model. Don't use @@ -554,7 +567,11 @@ define(["jquery", "underscore", "backbone", "collections/Citations"], ( getPidFromSourceModel(sourceModel) { try { const pid = - sourceModel.get("id") || sourceModel.get("identifier") || null; + sourceModel.get("id") || + sourceModel.get("identifier") || + sourceModel.get("doi") || + sourceModel.get("DOI") || + null; return pid; } catch (error) { console.log( @@ -587,6 +604,26 @@ define(["jquery", "underscore", "backbone", "collections/Citations"], ( } }, + /** + * Get the volume from the sourceModel. + * @param {Backbone.Model} sourceModel - The model to get the volume from + * @returns {number|string} - The volume + * @since 0.0.0 + */ + getVolumeFromSourceModel(sourceModel) { + return sourceModel.get("volume") || null; + }, + + /** + * Get the issue from the sourceModel. + * @param {Backbone.Model} sourceModel - The model to get the issue from + * @returns {number|string} - The issue + * @since 0.0.0 + */ + getIssueFromSourceModel(sourceModel) { + return sourceModel.get("issue") || null; + }, + /** * Use the sourceModel's createViewURL() method to get the viewUrl for the * citation. This method is built into DataONEObject models, SolrResult @@ -1079,19 +1116,26 @@ define(["jquery", "underscore", "backbone", "collections/Citations"], ( /** * Get the main identifier for the citation. This will check the model for * the following attributes and return the first that is not empty: pid, - * seriesId, source_url. + * seriesId, source_url, doi, DOI * @returns {string} Returns the main identifier for the citation or an * empty string. * @since 2.23.0 */ getID() { - const idSources = ["pid", "seriesId", "source_url"]; + const idSources = ["pid", "seriesId", "source_url", "doi", "DOI"]; for (let i = 0; i < idSources.length; i++) { const id = this.get(idSources[i]); if (id) return id; } return ""; }, + + /** Set the model back to its defaults */ + reset() { + this.clear({ silent: true }); + this.set(this.defaults(), { silent: true }); + this.trigger("change"); + }, }, ); diff --git a/src/js/models/CrossRefModel.js b/src/js/models/CrossRefModel.js new file mode 100644 index 000000000..7060399b4 --- /dev/null +++ b/src/js/models/CrossRefModel.js @@ -0,0 +1,115 @@ +define(["backbone"], (Backbone) => { + const CACHE_PREFIX = "crossref_"; + /** + * @class CrossRef + * @classdesc Handles querying CrossRef API for metadata about a DOI. + * @classcategory Models + * @augments Backbone.Model + * @constructs + * @augments Backbone.Model + * @since 0.0.0 + */ + const CrossRef = Backbone.Model.extend( + /** @lends CrossRef.prototype */ + { + /** @inheritdoc */ + type: "CrossRef", + + /** + * Defaults for the CrossRef model. + * @type {object} + * @property {string} baseURL - The base URL for the CrossRef API. + * @property {string} email - The email address to use for "polite" + * requests. See https://github.com/CrossRef/rest-api-doc#good-manners--more-reliable-service). + */ + defaults() { + return { + baseURL: + MetacatUI.appModel.get("crossRefAPI") || + "https://api.crossref.org/works/", + email: MetacatUI.appModel.get("emailContact") || "", + }; + }, + + /** @inheritdoc */ + url() { + let doi = this.get("doi"); + if (!doi) return null; + // Make sure the DOI is formatted correctly + doi = MetacatUI.appModel.removeAllDOIPrefixes(doi); + this.set("doi", doi); + const doiStr = encodeURIComponent(doi); + const email = this.get("email"); + const emailStr = email ? `?mailto:${email}` : ""; + const baseURL = this.get("baseURL"); + const url = `${baseURL}${doiStr}${emailStr}`; + return url; + }, + + /** @inheritdoc */ + fetch() { + // first check if there's a cached response + const doi = this.get("doi"); + const cachedResponse = this.getCachedResponse(doi); + if (cachedResponse) { + this.set(cachedResponse); + this.trigger("sync"); + return; + } + + const url = this.url(); + if (!url) return; + const model = this; + // Make the request using native fetch + fetch(url) + .then((response) => { + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); + }) + .then((responseJSON) => { + const parsedData = responseJSON.message; + model.cacheResponse(doi, parsedData); + model.set(parsedData); + model.trigger("sync"); + }) + .catch((error) => { + model.trigger("error", error); + model.set("error", "fetchError"); + model.set("errorMessage", error.message); + }); + }, + + /** + * Cache the response from the CrossRef API + * @param {string} doi The DOI for the response + * @param {object} response The response from the CrossRef API + */ + cacheResponse(doi, response) { + localStorage.setItem(`${CACHE_PREFIX}${doi}`, JSON.stringify(response)); + }, + + /** + * Get the cached response for a DOI + * @param {string} doi The DOI to get the cached response for + * @returns {object|null} The cached response or null if there is no cached response + */ + getCachedResponse(doi) { + const cachedResponse = localStorage.getItem(`${CACHE_PREFIX}${doi}`); + if (!cachedResponse) return null; + return JSON.parse(cachedResponse); + }, + + /** Clear the cache of CrossRef responses */ + clearCache() { + const keysToRemove = Object.keys(localStorage).filter((key) => + key.startsWith(CACHE_PREFIX), + ); + keysToRemove.forEach((key) => localStorage.removeItem(key)); + }, + }, + ); + + return CrossRef; +}); diff --git a/src/js/templates/citations/citationAPA.html b/src/js/templates/citations/citationAPA.html index d268a8a93..d8f016189 100644 --- a/src/js/templates/citations/citationAPA.html +++ b/src/js/templates/citations/citationAPA.html @@ -8,11 +8,18 @@ let titleHTML = title ? `${title}. ` : ''; -let journalHTML = journal ? `${journal}. ` : ''; +let issueHTML = issue ? issue : ''; +issueHTML = volume && issue ? `(${issue})` : ''; -let volumeHTML = volume ? `Vol. ${volume}. ` : ''; +let volumeIssueHTML = volume || issueHTML ? `${volume}${issueHTML}` : ''; -let pageHTML = page ? `pp. ${page}. ` : ''; +let pageHTML = page ? `${page}` : ''; + +let journalHTML = journal || ''; +journalHTML = volumeIssueHTML || pageHTML ? `${journalHTML}, ` : journalHTML; +journalHTML = volumeIssueHTML ? `${journalHTML}${volumeIssueHTML}` : journalHTML; +journalHTML = pageHTML ? `${journalHTML}, ${pageHTML}` : journalHTML; +journalHTML = journalHTML ? `${journalHTML}. ` : journalHTML; let seriesIdHTML = seriesId || ''; if (seriesId) { @@ -34,7 +41,7 @@ const idHTML = seriesIdHTML || pidHTML ? `${seriesIdHTML}${pidHTML}` : ''; %> -<%= originHTML + pubHTML + titleHTML + journalHTML + volumeHTML + pageHTML + idHTML %> +<%= originHTML + pubHTML + titleHTML + journalHTML + idHTML %> <%if(citationMetadata){%>
Cites Data: diff --git a/src/js/templates/metadataInfoIcons.html b/src/js/templates/metadataInfoIcons.html index 6baafa2db..04ed7cc3b 100644 --- a/src/js/templates/metadataInfoIcons.html +++ b/src/js/templates/metadataInfoIcons.html @@ -1,3 +1,8 @@ +<% + // This template is now *** DEPRECATED *** in favour of in-view rendering. + // To be removed in a future release. +%> + <% if( !model.isPublic || model.archived ){ %> <% } %> diff --git a/src/js/views/AnnotationView.js b/src/js/views/AnnotationView.js index e9cbbe25b..eac56a15c 100644 --- a/src/js/views/AnnotationView.js +++ b/src/js/views/AnnotationView.js @@ -16,6 +16,11 @@ define([ */ var AnnotationView = Backbone.View.extend( /** @lends AnnotationView.prototype */ { + /** + * The type of View this is + * @type {string} + */ + type: "AnnotationView", className: "annotation-view", annotationPopoverTemplate: _.template(AnnotationPopoverTemplate), diff --git a/src/js/views/CanonicalDatasetHandlerView.js b/src/js/views/CanonicalDatasetHandlerView.js new file mode 100644 index 000000000..456ee5015 --- /dev/null +++ b/src/js/views/CanonicalDatasetHandlerView.js @@ -0,0 +1,393 @@ +define([ + "backbone", + "views/CitationView", + "models/CitationModel", + "models/CrossRefModel", +], (Backbone, CitationView, CitationModel, CrossRefModel) => { + // The "Type" property of the annotation view + const ANNO_VIEW_TYPE = "AnnotationView"; + // The URI for the schema.org:sameAs annotation + const SCHEMA_ORG_SAME_AS = "http://www.w3.org/2002/07/owl#sameAs"; + // The URI for the prov:wasDerivedFrom annotation + const PROV_WAS_DERIVED_FROM = "http://www.w3.org/ns/prov#wasDerivedFrom"; + + // The text to show in the alert box at the top of the MetadataView + const ALERT_TEXT = + "This version of the dataset is a replica or minor variant of the original dataset:"; + // The text to display in the info tooltip to explain what the info icon means + const INFO_ICON_TOOLTIP_TEXT = + "This dataset is replica or minor variant of another, original dataset."; + // In the citation modal, the heading to use for the dataone version citation + const CITATION_TITLE_DATAONE = "This Version of the Dataset"; + // In the citation modal, the heading to use for the canonical dataset + // citation + const CITATION_TITLE_CANONICAL = "Canonical Dataset"; + // The class to use for the info icon + const INFO_ICON_CLASS = "info"; + // The bootstrap icon name to use for the info icon + const INFO_ICON_NAME = "icon-copy"; + + // Class names used in this view + const CLASS_NAMES = { + alertBox: ["alert", "alert-info", "alert-block"], // TODO: need alert-block? + alertIcon: ["icon", "icon-info-sign", "icon-on-left"], + alertCitation: "canonical-citation", + }; + + // The following properties are used to identify parts of the MetadataView. + // If the MetadataView changes, these properties may need to be updated. + const METADATA_VIEW = { + // The selector for the container that contains the info icons and metrics buttons + controlsSelector: "#metadata-controls-container", + // The name of the property on the MetadataView that contains the citation + // modal + citationModalProp: "citationModal", + // The name of the property on the MetadataView that contains subviews + subviewProp: "subviews", + }; + + /** + * @class CanonicalDatasetHandlerView + * @classdesc A scoped subview responsible for inspecting the rendered DOM + * within the MetadataView to identify and highlight the canonical (original) + * dataset based on schema.org:sameAs and prov:derivedFrom annotations. This + * view modifies specific parts of the MetadataView when a canonical dataset + * is detected, providing a clearer distinction between original and derived + * datasets. + * @classcategory Views + * @augments Backbone.View + * @class + * @since 0.0.0 + * @screenshot views/CanonicalDatasetHandlerViewView.png + */ + const CanonicalDatasetHandlerView = Backbone.View.extend( + /** @lends CanonicalDatasetHandlerView.prototype */ + { + /** @inheritdoc */ + type: "CanonicalDatasetHandlerView", + + /** + * The MetadataView instance this view is scoped to. + * @type {MetadataView} + */ + metdataView: null, + + /** + * Initialize the CanonicalDatasetHandlerView. + * @param {object} options - A set of options to initialize the view with. + * @param {MetadataView} options.metadataView - The MetadataView instance + * this view is scoped to. Required. + */ + initialize(options) { + this.metadataView = options?.metadataView; + this.citationModel = new CitationModel(); + if (!this.metadataView) { + throw new Error( + "The CanonicalDatasetHandlerView requires a MetadataView instance.", + ); + } + }, + + /** @inheritdoc */ + render() { + // In case it's a re-render, remove any modifications made previously + this.reset(); + const hasCanonical = this.detectCanonicalDataset(); + if (!hasCanonical) return this; + this.infoIcon = this.addInfoIcon(); + this.alertBox = this.addAlertBox(); + this.getCitationInfo(); + this.modifyCitationModal(); + this.hideAnnotations(); + return this; + }, + + /** + * Resets the MetadataView to its original state by removing any changes + * made by this view. + */ + reset() { + this.infoIcon?.remove(); + this.alertBox?.remove(); + this.showAnnotations(); + this.citationModel.reset(); + this.canonicalUri = null; + }, + + /** + * Inspects the MetadataView DOM to determine if a canonical dataset is + * present based on schema.org:sameAs and prov:wasDerivedFrom annotations. + * If a canonical dataset is detected, this method sets the appropriate + * properties on the view instance. + * @returns {boolean} True if a canonical dataset is detected, false + * otherwise. + */ + detectCanonicalDataset() { + const matches = this.findCanonicalAnnotations(); + if (!matches) return false; + this.canonicalUri = matches.uri; + return true; + }, + + /** + * Given a set annotation views for the sameAs property and a set of + * annotation views for the derivedFrom property, this method finds any + * matches between the two sets. A match is found if the URI of the sameAs + * annotation is the same as the URI of the derivedFrom annotation. + * @returns {{sameAs: AnnotationView, derivedFrom: AnnotationView, uri: string}} + * An object containing the matching sameAs and derivedFrom annotation and + * the URI they share. + */ + findCanonicalAnnotations() { + // The annotation views provide the URI and value of annotations on the + // metadata. We consider the dataset to be canonical if the sameAs and + // derivedFrom annotations both point to the same URI. + const sameAs = this.getSameAsAnnotationViews(); + if (!sameAs?.length) return null; + const derivedFrom = this.getDerivedFromAnnotationViews(); + if (!derivedFrom?.length) return null; + + const sameAsUnique = this.removeDuplicateAnnotations(sameAs); + const derivedFromUnique = this.removeDuplicateAnnotations(derivedFrom); + + // Find any matches between the two sets + const matches = []; + sameAsUnique.forEach((sameAsAnno) => { + derivedFromUnique.forEach((derivedFromAnno) => { + if (sameAsAnno.value.uri === derivedFromAnno.value.uri) { + matches.push({ + sameAs: sameAsAnno, + derivedFrom: derivedFromAnno, + uri: sameAsAnno.value.uri, + }); + } + }); + }); + // There can only be one canonical dataset. If multiple matches are + // found, we cannot determine the canonical dataset. + if (!matches.length || matches.length > 1) return null; + return matches[0]; + }, + + /** + * Removes duplicate annotations from an array of AnnotationView instances. + * @param {AnnotationView[]} annotationViews An array of AnnotationView all + * with the same property URI. + * @returns {AnnotationView[]} An array of AnnotationView instances with + * duplicates removed. + */ + removeDuplicateAnnotations(annotationViews) { + return annotationViews.filter( + (anno, i, self) => + i === self.findIndex((a) => a.value.uri === anno.value.uri), + ); + }, + + /** + * Gets all annotation views from the MetadataView. + * @returns {AnnotationView[]} An array of AnnotationView instances. + */ + getAnnotationViews() { + return this.metadataView[METADATA_VIEW.subviewProp].filter( + (view) => view?.type === ANNO_VIEW_TYPE, + ); + }, + + /** + * Gets the AnnotationView for the schema.org:sameAs annotation. + * @returns {AnnotationView[]} An array of sameAs AnnotationViews. + */ + getSameAsAnnotationViews() { + return this.getAnnotationViews().filter( + (view) => view.property.uri === SCHEMA_ORG_SAME_AS, + ); + }, + + /** + * Gets the AnnotationView for the prov:wasDerivedFrom annotation. + * @returns {AnnotationView[]} An array of derivedFrom AnnotationViews. + */ + getDerivedFromAnnotationViews() { + return this.getAnnotationViews().filter( + (view) => view.property.uri === PROV_WAS_DERIVED_FROM, + ); + }, + + /** + * Given the canonical dataset URI, fetches citation information for the + * canonical dataset, like the title, authors, publication date, etc. Saves + * this information in a CitationModel instance. + */ + getCitationInfo() { + const view = this; + // Set the URL as the url for now, incase it is not a DOI or we fail to + // fetch the citation information. + this.citationModel.set({ + pid: this.canonicalUri, + pid_url: this.canonicalUri, + }); + + this.crossRef = new CrossRefModel({ + doi: this.canonicalUri, + }); + this.stopListening(this.crossRef); + this.listenToOnce(this.crossRef, "sync", () => { + view.citationModel.setSourceModel(this.crossRef); + view.updateAlertBox(); + }); + this.crossRef.fetch(); + }, + + /** + * Hides the sameAs and derivedFrom annotations from the MetadataView. + * This is done to prevent redundancy in the metadata display. + */ + hideAnnotations() { + // Sometimes the MetadataView re-renders, so we must always query for + // the annotation views when we want to remove them. + const sameAs = this.getSameAsAnnotationViews(); + const derivedFrom = this.getDerivedFromAnnotationViews(); + sameAs.forEach((sameAsAnno) => { + if (sameAsAnno?.value.uri === this.canonicalUri) { + const view = sameAsAnno; + view.el.style.display = "none"; + if (!this.hiddenSameAs) this.hiddenSameAs = []; + this.hiddenSameAs.push(sameAsAnno); + } + }); + derivedFrom.forEach((derivedFromAnno) => { + if (derivedFromAnno?.value.uri === this.canonicalUri) { + const view = derivedFromAnno; + view.el.style.display = "none"; + if (!this.hiddenDerivedFrom) this.hiddenDerivedFrom = []; + this.hiddenDerivedFrom.push(derivedFromAnno); + } + }); + }, + + /** Show previously hidden annotations in the MetadataView. */ + showAnnotations() { + this.hiddenSameAs?.el.style.removeProperty("display"); + this.hiddenSameAs = null; + this.hiddenDerivedFrom?.el.style.removeProperty("display"); + this.hiddenDerivedFrom = null; + }, + + /** + * Adds an alert box to the top of the MetadataView to indicate that the + * dataset being displayed is a replica or minor variant of the original + * dataset. + * @returns {Element} The alert box element that was added to the view. + */ + addAlertBox() { + const controls = this.metadataView.el.querySelector( + METADATA_VIEW.controlsSelector, + ); + + const alertBox = document.createElement("section"); + alertBox.classList.add(...CLASS_NAMES.alertBox); + + const icon = document.createElement("i"); + icon.classList.add(...CLASS_NAMES.alertIcon); + + const heading = document.createElement("h5"); + heading.textContent = ALERT_TEXT; + + // Add a div that will contain the citation information + const citeContainer = document.createElement("div"); + citeContainer.classList.add(CLASS_NAMES.alertCitation); + // Just add the URI for now + citeContainer.textContent = this.canonicalUri; + + heading.prepend(icon); + alertBox.append(heading, citeContainer); + + alertBox.style.marginTop = "-1rem"; + heading.style.marginTop = "0"; + citeContainer.style.marginLeft = "1rem"; + + this.alertBox = alertBox; + + // Insert the citation view before the metadata controls + controls.before(alertBox); + + return alertBox; + }, + + /** Updates the citation information in the alert box. */ + updateAlertBox() { + const alertBox = this.alertBox || this.addAlertBox(); + const citeContainer = alertBox.querySelector( + `.${CLASS_NAMES.alertCitation}`, + ); + const { citationModel } = this; + const citationView = new CitationView({ + model: citationModel, + // Don't use styles from default class + className: "", + createTitleLink: false, + openLinkInNewTab: true, + }).render(); + citeContainer.innerHTML = ""; + citeContainer.appendChild(citationView.el); + }, + + /** Open the citation modal. */ + openCitationModal() { + this.metadataView[METADATA_VIEW.citationModalProp].show(); + }, + + /** + * Modifies the CitationModalView to add the citation information for the + * canonical dataset in addition to the citation information for the + * current dataset. + */ + modifyCitationModal() { + const view = this; + // The CitationModalView is recreated each time it is shown. + const citationModalView = + this.metadataView[METADATA_VIEW.citationModalProp]; + this.listenToOnce(citationModalView, "rendered", () => { + citationModalView.canonicalDatasetMods = true; + // Add heading for each citation + const heading = document.createElement("h5"); + heading.textContent = CITATION_TITLE_DATAONE; + citationModalView.citationContainer.prepend(heading); + + // Add the citation for the canonical dataset + citationModalView.insertCitation(view.citationModel, false); + + // Add a heading for the canonical dataset citation + const headingOriginal = document.createElement("h5"); + headingOriginal.textContent = CITATION_TITLE_CANONICAL; + citationModalView.citationContainer.prepend(headingOriginal); + }); + }, + + /** + * Adds a icon to the header of the MetadataView to indicate that the + * dataset being displayed is essentially a duplicate + * @returns {Element} The info icon element that was added to the view. + */ + addInfoIcon() { + const infoIcon = this.metadataView.addInfoIcon( + "duplicate", + INFO_ICON_NAME, + INFO_ICON_CLASS, + INFO_ICON_TOOLTIP_TEXT, + ); + infoIcon.style.cursor = "pointer"; + infoIcon.addEventListener("click", () => this.openCitationModal()); + return infoIcon; + }, + + /** Called when the view is removed. */ + onClose() { + this.reset(); + this.remove(); + }, + }, + ); + + return CanonicalDatasetHandlerView; +}); diff --git a/src/js/views/MetadataView.js b/src/js/views/MetadataView.js index 1bcbed1b4..82cd90078 100644 --- a/src/js/views/MetadataView.js +++ b/src/js/views/MetadataView.js @@ -22,13 +22,13 @@ define([ "views/AnnotationView", "views/MarkdownView", "views/ViewObjectButtonView", + "views/CanonicalDatasetHandlerView", "text!templates/metadata/metadata.html", "text!templates/dataSource.html", "text!templates/publishDOI.html", "text!templates/newerVersion.html", "text!templates/loading.html", "text!templates/metadataControls.html", - "text!templates/metadataInfoIcons.html", "text!templates/alert.html", "text!templates/editMetadata.html", "text!templates/dataDisplay.html", @@ -59,13 +59,13 @@ define([ AnnotationView, MarkdownView, ViewObjectButtonView, + CanonicalDatasetHandlerView, MetadataTemplate, DataSourceTemplate, PublishDoiTemplate, VersionTemplate, LoadingTemplate, ControlsTemplate, - MetadataInfoIconsTemplate, AlertTemplate, EditMetadataTemplate, DataDisplayTemplate, @@ -117,7 +117,6 @@ define([ versionTemplate: _.template(VersionTemplate), loadingTemplate: _.template(LoadingTemplate), controlsTemplate: _.template(ControlsTemplate), - infoIconsTemplate: _.template(MetadataInfoIconsTemplate), dataSourceTemplate: _.template(DataSourceTemplate), editMetadataTemplate: _.template(EditMetadataTemplate), dataDisplayTemplate: _.template(DataDisplayTemplate), @@ -169,6 +168,13 @@ define([ /** @inheritdoc */ render() { + if (this.isRendering) { + // If we re-render before the first render is complete the view breaks + this.stopListening(this, "renderComplete", this.render); + this.listenToOnce(this, "renderComplete", this.render); + return this; + } + this.isRendering = true; this.stopListening(); MetacatUI.appModel.set("headerType", "default"); @@ -186,9 +192,14 @@ define([ this.listenTo(MetacatUI.appUserModel, "change:loggedIn", this.render); // Listen to when the metadata has been rendered - this.once("metadataLoaded", () => { + this.listenToOnce(this, "metadataLoaded", () => { this.createAnnotationViews(); this.insertMarkdownViews(); + // Modifies the view to indicate that this is a dataset is essentially + // a duplicate of another dataset, if applicable + this.canonicalDatasetHandler = new CanonicalDatasetHandlerView({ + metadataView: this, + }).render(); }); // Listen to when the package table has been rendered @@ -416,7 +427,9 @@ define([ }); // Listen to 404 and 401 errors when we get the metadata object + this.stopListening(model, "404"); this.listenToOnce(model, "404", this.showNotFound); + this.stopListening(model, "401"); this.listenToOnce(model, "401", this.showIsPrivate); // Fetch the model @@ -531,14 +544,16 @@ define([ viewRef.alterMarkup(); - viewRef.trigger("metadataLoaded"); - // Add a map of the spatial coverage if (gmaps) viewRef.insertSpatialCoverageMap(); // Injects Clipboard objects into DOM elements returned from // the View Service viewRef.insertCopiables(); + + viewRef.trigger("metadataLoaded"); + viewRef.isRendering = false; + viewRef.trigger("renderComplete"); } } catch (e) { MetacatUI.analytics?.trackException( @@ -767,6 +782,11 @@ define([ // bit until we show a 401 msg, in case this content is their private // content if (!MetacatUI.appUserModel.get("checked")) { + this.stopListening( + MetacatUI.appUserModel, + "change:checked", + this.showIsPrivate, + ); this.listenToOnce( MetacatUI.appUserModel, "change:checked", @@ -774,6 +794,8 @@ define([ ); return; } + this.isRendering = false; + this.trigger("renderComplete"); let msg = ""; @@ -1796,12 +1818,7 @@ define([ $(this.controlsContainer).html(controlsContainer); // Insert the info icons - const metricsWell = this.$(".metrics-container"); - metricsWell.append( - this.infoIconsTemplate({ - model: this.model.toJSON(), - }), - ); + this.renderInfoIcons(); if (MetacatUI.appModel.get("showWholeTaleFeatures")) { this.createWholeTaleButton(); @@ -1810,23 +1827,93 @@ define([ // Show the citation modal with the ability to copy the citation text // when the "Copy Citation" button is clicked const citeButton = this.el.querySelector("#cite-this-dataset-btn"); + this.citationModal = new CitationModalView({ + model: this.model, + createLink: true, + }); + this.subviews.push(this.citationModal); + this.citationModal.render(); if (citeButton) { citeButton.removeEventListener("click", this.citationModal); citeButton.addEventListener( "click", () => { - this.citationModal = new CitationModalView({ - model: this.model, - createLink: true, - }); - this.subviews.push(this.citationModal); - this.citationModal.render(); + this.citationModal.show(); }, false, ); } }, + /** + * Add the info icons to the metadata controls panel. Shows if the dataset + * is private or archived. + * @since 0.0.0 + */ + renderInfoIcons() { + const isPrivate = !this.model.get("isPublic"); + const isArchived = this.model.get("archived"); + if (!isPrivate && !isArchived) return; + + if (isPrivate) { + this.addInfoIcon( + "private", + "icon-lock", + "private", + "This is a private dataset.", + ); + } + if (isArchived) { + this.addInfoIcon( + "archived", + "icon-trash", + "danger", + "This dataset has been archived.", + ); + } + }, + + /** + * Add an info icon to the metadata controls panel. + * @param {string} iconType - The type of icon to add. + * @param {string} iconClass - The class + * @param {string} baseClass - The base class + * @param {string} titleText - The text to display when the icon is hovered + * over. + * @returns {HTMLElement} The icon element that was added to the view. + * @since 0.0.0 + */ + addInfoIcon(iconType, iconClass, baseClass, titleText) { + const iconHTML = ` + + + + + `; + + // Convert the string into DOM element so we can return it + const range = document.createRange(); + const newIconFragment = range.createContextualFragment(iconHTML); + const newIcon = newIconFragment.firstChild; + + const iconContainerClass = "info-icons"; + let iconContainer = this.el.querySelector(`.${iconContainerClass}`); + if (!iconContainer) { + iconContainer = $(document.createElement("span")).addClass( + iconContainerClass, + ); + this.$(".metrics-container").prepend(iconContainer); + } + + iconContainer.append(newIcon); + + return newIcon; + }, + /** *Creates a button which the user can click to launch the package in Whole *Tale diff --git a/src/js/views/citations/CitationModalView.js b/src/js/views/citations/CitationModalView.js index e0912950f..6a8458bee 100644 --- a/src/js/views/citations/CitationModalView.js +++ b/src/js/views/citations/CitationModalView.js @@ -6,7 +6,7 @@ define([ "models/CitationModel", "views/CitationView", "text!templates/citations/citationModal.html", -], function ($, _, Backbone, Clipboard, Citation, CitationView, Template) { +], ($, _, Backbone, Clipboard, Citation, CitationView, Template) => { "use strict"; /** @@ -17,7 +17,7 @@ define([ * @classcategory Views * @extends Backbone.View */ - var CitationModalView = Backbone.View.extend( + const CitationModalView = Backbone.View.extend( /** @lends CitationModalView.prototype */ { /** * Classes to add to the modal @@ -131,7 +131,6 @@ define([ // Set listeners this.$el.off("shown"); this.$el.on("shown", this.renderView.bind(this)); - this.show(); return this; }, @@ -176,6 +175,7 @@ define([ this.insertCitation(); this.listenForCopy(); + this.trigger("rendered"); } catch (e) { console.error("Failed to render the Citation Modal View: ", e); MetacatUI.appView.showAlert({ @@ -191,24 +191,38 @@ define([ }, /** - * Insert the citation view into the modal + * Insert the citation view into the modal. This can be used by parent + * views to insert additional citation views into the modal. + * @param {CitationModel} model - The citation model to use. If not + * provided, the model passed to the view will be used. + * @param {boolean} after - If true, the citation will be inserted after + * any existing citations. If false, the citation will be inserted before + * any existing citations. + * @returns {CitationView} - Returns the CitationView that was inserted + * into the modal */ - insertCitation: function () { + insertCitation(model, after = true) { const container = this.citationContainer; - if (!container) return; + if (!container) return null; // Create a new CitationView - var citationView = new CitationView({ - model: this.model, + const citationView = new CitationView({ + model: model || this.model, style: this.style, createTitleLink: false, }); // Render the CitationView - citationView.render(); + const citationEl = citationView.render().el; // Insert the CitationView into the modal - container.appendChild(citationView.el); + if (after) { + container.appendChild(citationEl); + } else { + container.prepend(citationEl); + } + + return citationView; }, /** diff --git a/test/config/tests.json b/test/config/tests.json index 6eef2b901..88fe3b759 100644 --- a/test/config/tests.json +++ b/test/config/tests.json @@ -18,6 +18,7 @@ "./js/specs/unit/models/filters/Filter.spec.js", "./js/specs/unit/models/filters/NumericFilter.spec.js", "./js/specs/unit/models/CitationModel.spec.js", + "./js/specs/unit/models/CrossRefModel.spec.js", "./js/specs/unit/collections/ProjectList.spec.js", "./js/specs/unit/collections/DataPackage.spec.js", "./js/specs/unit/models/project/Project.spec.js", diff --git a/test/js/specs/unit/models/CrossRefModel.spec.js b/test/js/specs/unit/models/CrossRefModel.spec.js new file mode 100644 index 000000000..b226ddaa8 --- /dev/null +++ b/test/js/specs/unit/models/CrossRefModel.spec.js @@ -0,0 +1,37 @@ +"use strict"; + +define(["/test/js/specs/shared/clean-state.js", "models/CrossRefModel"], ( + cleanState, + CrossRef, +) => { + const should = chai.should(); + const expect = chai.expect; + + describe("CrossRef Test Suite", () => { + const state = cleanState(() => { + // Example DOI from: + + // Jerrentrup, A., Mueller, T., Glowalla, U., Herder, M., Henrichs, N., + // Neubauer, A., & Schaefer, J. R. (2018). Teaching medicine with the + // help of “Dr. House.” PLoS ONE, 13(3), Article e0193972. + // https://doi.org/10.1371/journal.pone.0193972 + const crossRef = new CrossRef({ + doi: "https://doi.org/10.1371/journal.pone.0193972", + }); + return { crossRef }; + }, beforeEach); + + it("creates a CrossRef instance", () => { + state.crossRef.should.be.instanceof(CrossRef); + }); + + it("forms valid fetch URLs", () => { + const url = state.crossRef.url(); + + url.should.be.a("string"); + url.should.include("https://api.crossref.org/works/"); + url.should.include("10.1371%2Fjournal.pone.0193972"); + url.should.include("?mailto:knb-help@nceas.ucsb.edu"); + }); + }); +});