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");
+ });
+ });
+});