"use strict";
define([
"jquery",
"backbone",
"common/Utilities",
"common/DateUtility",
"semantic",
], ($, Backbone, Utilities, DateUtility, Semantic) => {
// SVG for the star icon used in the DOI badge
const starIcon = `<i class="icon-star"></i>`;
// The prefix for BEM-style class names for this view
const BASE_CLASS = "object-version";
// Semantic UI class names for variations, used in tooltipSettings
const SEM_VARIATIONS = Semantic.CLASS_NAMES.variations;
const DATE_NOTE_MESSAGE = (timeDiff) =>
`<b>Note on dates:</b> This version was uploaded ${timeDiff} ` +
`after a newer version in the history. This can occur when version ` +
`relationships are updated later as part of record curation.`;
/**
* CSS class names used throughout the ObjectVersionView.
* @enum {string}
*/
const CLASS_NAMES = {
base: BASE_CLASS,
title: `${BASE_CLASS}__title`,
link: `${BASE_CLASS}__link`,
badgesContainer: `${BASE_CLASS}__badges`,
badge: `object-version__badge`,
dateNote: `${BASE_CLASS}__date-note`,
date: `${BASE_CLASS}__date`,
size: `${BASE_CLASS}__size`,
hidden: `version-history--hidden`,
doi: `${BASE_CLASS}--doi`,
badgeTypes: {
FIRST: `${BASE_CLASS}__badge--first`,
LATEST: `${BASE_CLASS}__badge--latest`,
NEWER: `${BASE_CLASS}__badge--newer`,
OLDER: `${BASE_CLASS}__badge--older`,
CURRENT: `${BASE_CLASS}__badge--current`,
PRIVATE: `${BASE_CLASS}__badge--private`,
NOTFOUND: `${BASE_CLASS}__badge--not-found`,
DOI: `${BASE_CLASS}__badge--doi`,
},
// bootstrap
alert: "alert",
alertInfo: "alert-info",
};
const STATUS = {
FIRST: {
label: "First",
className: CLASS_NAMES.badgeTypes.FIRST,
description: "This version is the first in the series.",
},
LATEST: {
label: "Latest",
className: CLASS_NAMES.badgeTypes.LATEST,
description: "This version is the latest in the series.",
},
NEWER: {
label: (timeDiff) => `${timeDiff} Newer`,
className: CLASS_NAMES.badgeTypes.NEWER,
description: (timeDiff) =>
`This version was created ${timeDiff} after the reference version.`,
},
OLDER: {
label: (timeDiff) => `${timeDiff} Older`,
className: CLASS_NAMES.badgeTypes.OLDER,
description: (timeDiff) =>
`This version was created ${timeDiff} before the reference version.`,
},
AHEAD: {
label: (index, versionWord) => `${index} ${versionWord} Ahead`,
className: CLASS_NAMES.badgeTypes.NEWER,
description: (versionDiff, versionWord) =>
`This version is ${versionDiff} ${versionWord} ahead of the reference version`,
},
BEHIND: {
label: (index, versionWord) => `${index} ${versionWord} Behind`,
className: CLASS_NAMES.badgeTypes.OLDER,
description: (versionDiff, versionWord) =>
`This version is ${versionDiff} ${versionWord} behind the reference version`,
},
CURRENT: {
label: "This Version",
className: CLASS_NAMES.badgeTypes.CURRENT,
description:
"All other versions are shown relative to this reference version.",
},
PRIVATE: {
label: "Private",
className: CLASS_NAMES.badgeTypes.PRIVATE,
description:
"This version is private and cannot be accessed with your current credentials. Login with an account that has access to this version to view it.",
},
NOTFOUND: {
label: "Not Found",
className: CLASS_NAMES.badgeTypes.NOTFOUND,
description:
"This version is referenced in the history but was not found in this data repository.",
},
DOI: {
label: `${starIcon} DOI`,
className: CLASS_NAMES.badgeTypes.DOI,
description:
"This version has been published with a Data Object Identifier (DOI).",
},
};
/**
* @class ObjectVersionView
* @classdesc Renders a single object version as an <li> element. An object
* version displays the identifier, upload date, and a status badge indicating
* whether this version is the first, latest, newer, or older relative to a
* reference version. If the version is private or not found, appropriate
* badges are shown. Clicking the version's identifier link opens the object's
* view page in a new tab.
* @classcategory Views/VersionHistory
* @augments Backbone.View
* @screenshot views/VersionHistory/ObjectVersionView.png
* @since 2.37.0
*/
const ObjectVersionView = Backbone.View.extend(
/** @lends ObjectVersionView.prototype */ {
/**
* Identifier used when the application inspects view types.
* @type {string}
*/
type: "ObjectVersionView",
/** @inheritdoc */
tagName: "li",
/** @inheritdoc */
className: CLASS_NAMES.base,
/**
* Settings passed to the Formantic UI popup module to configure a tooltip
* shown over item titles. The item must have a description set in order
* for the tooltip to be shown.
* @see https://fomantic-ui.com/modules/popup.html#/settings
* @type {object|boolean}
*/
tooltipSettings: {
variation: `${SEM_VARIATIONS.mini} ${SEM_VARIATIONS.inverted}`,
position: "top center",
on: "hover",
hoverable: true,
delay: {
show: 250,
hide: 0,
},
},
/**
* Add tooltips to the badge elements immediately after rendering. False
* by default since tooltips must be added only once the view is in the
* DOM. This view is added to a Document Fragment during rendering of the
* timeline, which prevents tooltips from being added at that stage.
*/
autoAddTooltips: false,
/**
* Initializes the ObjectVersionView.
* @param {object} options Options to configure the view.
* @param {DataONEObject} options.model - The DataONEObject model to
* represent in this view.
* @param {string} [options.referencePid] The identifier of the reference
* version to compare against for relative date badges. If not provided,
* no relative date badges will be shown.
* @param {object} [options.tooltipSettings] Optional settings to override
* the default tooltipSettings for this view instance. See tooltipSettings
* property for details on available settings and defaults.
* @param {boolean} [options.autoAddTooltips] Set to true to automatically
* add tooltips after rendering. Defaults to false.
*/
initialize(options) {
this.model = options.model;
this.referencePid = options.referencePid || null;
if (
options.tooltipSettings &&
typeof options.tooltipSettings === "object"
) {
this.tooltipSettings = options.tooltipSettings;
}
if (options.autoAddTooltips === true) {
this.autoAddTooltips = true;
}
this.listenTo(
this.model,
"change:hiddenByUI",
this.applyVisibilityState,
);
this.listenTo(this.model, "change", this.onModelChange);
},
/**
* Handle model change events and avoid full re-render when only the
* visibility flag changed.
*/
onModelChange() {
const changed = this.model.changedAttributes() || {};
const keys = Object.keys(changed);
if (keys.length === 1 && keys[0] === "hiddenByUI") {
return;
}
this.render();
},
/** Show or hide the view based on the model's hiddenByUI attribute */
applyVisibilityState() {
this.el.classList.toggle(
`${CLASS_NAMES.hidden}`,
this.model.get("hiddenByUI") === true,
);
},
/**
* Determines if the current version is a DOI based on its identifier.
* @returns {boolean} True if the version is a DOI, false otherwise.
*/
isDOI() {
const identifier = this.model.get("identifier");
if (!identifier) return false;
return this.model.isDOI(identifier);
},
/**
* Generates the markup representing a single version entry.
* @param {DataONEObject} model The DataONEObject model to represent.
* @returns {string} The HTML string.
*/
template(model) {
const { identifier, dateUploaded } = model.toJSON();
let friendlyDate = DateUtility.toLocalTimestampWithZone(dateUploaded);
let isoDate = DateUtility.toISOString(dateUploaded);
if (!friendlyDate) {
friendlyDate = "Unknown Date";
isoDate = "";
}
const viewUrl = this.createViewURL(model);
const htmlIdentifier = Utilities.encodeHTML(identifier);
const doiBadge = this.getDOIBadge();
const dateBadge = this.getRelativeDateBadge();
const errorBadge = this.getErrorBadge();
const keyPointsBadge = this.getKeyPointsBadge();
const versionOrderBadge = this.getVersionOrderBadge();
const dateNote = this.getDateNote();
return `
<time class="${CLASS_NAMES.date}" datetime="${isoDate}" title="${friendlyDate}">${friendlyDate}</time>
<a href="${viewUrl}" class="${CLASS_NAMES.link}" target="_blank" rel="noopener">
<h4 class="${CLASS_NAMES.title}">${htmlIdentifier}</h4>
</a>
<div class ="${CLASS_NAMES.badgesContainer}">
${doiBadge}
${errorBadge}
${keyPointsBadge}
${versionOrderBadge}
${dateBadge}
</div>
${dateNote}
`;
},
/**
* Build a MetacatUI view URL for the given model.
* @param {DataONEObject} model Model to build the URL for.
* @returns {string} View URL for the object.
*/
createViewURL(model) {
// DataONEObject.createViewURL prefers seriesId here.
return `${MetacatUI.root}/view/${encodeURIComponent(
model.get("identifier"),
)}`;
},
/**
* Special badge for versions that are first or last in the version
* history, or the current reference version.
* @returns {string} HTML string for the badge.
*/
getKeyPointsBadge() {
const { obsoletes, obsoletedBy } = this.model.toJSON();
let status = null;
if (!obsoletes && obsoletedBy) {
status = STATUS.FIRST;
}
if (obsoletes && !obsoletedBy) {
status = STATUS.LATEST;
}
return this.createBadge(status);
},
/**
* Get the badge that indicates how old or new this version is (based on
* dateUploaded) relative to the reference version. If it's the reference
* version, return the "current" badge.
* @returns {string} HTML string for the badge.
*/
getRelativeDateBadge() {
let status = null;
if (this.referencePid === this.model.get("identifier")) {
return "";
}
const { collection } = this.model;
const referenceModel = collection?.findWhere({
identifier: this.referencePid,
});
if (!referenceModel) {
return "";
}
const referenceDate = referenceModel.get("dateUploaded");
const thisDate = this.model.get("dateUploaded");
if (!referenceDate || !thisDate) {
return "";
}
const thisDateObj = DateUtility.toDate(thisDate);
const referenceDateObj = DateUtility.toDate(referenceDate);
const relativeDateString = DateUtility.getRelativeDateString(
thisDateObj,
referenceDateObj,
{
newerWord: "",
olderWord: "",
currentWord: "current",
},
);
if (relativeDateString === "current") {
return "";
}
if (thisDate > referenceDate) {
status = STATUS.NEWER;
}
if (thisDate < referenceDate) {
status = STATUS.OLDER;
}
if (!status) return "";
return this.createBadge({
label: status.label(relativeDateString),
className: status.className,
description: status.description(relativeDateString),
});
},
/**
* Get the badge that indicates how many versions ahead or behind this
* version is relative to the reference version, based on the version
* history index. If it's the reference version, return the "current"
* badge.
* @returns {string} HTML string for the badge.
*/
getVersionOrderBadge() {
const vh = this.model.get("versionHistory");
const refPid = this.referencePid;
if (!vh || !refPid) return "";
const index = vh[refPid];
if (!index && index !== 0) return "";
const absIndex = Math.abs(index);
const versionWord = absIndex === 1 ? "version" : "versions";
let settings = null;
if (absIndex === 0) {
settings = STATUS.CURRENT;
} else {
settings = index > 0 ? STATUS.AHEAD : STATUS.BEHIND;
settings = { ...settings };
settings.label = settings.label(absIndex, versionWord);
settings.description = settings.description(absIndex, versionWord);
}
return this.createBadge(settings);
},
/**
* Get the badge that indicates any errors or warnings, like 401s or 404s.
* @returns {string} HTML string for the badge.
*/
getErrorBadge() {
const errors = this.model.get("errors");
let badges = "";
if (errors?.length) {
errors.forEach((error) => {
if (error === 401) {
badges += this.createBadge(STATUS.PRIVATE);
} else if (error === 404) {
badges += this.createBadge(STATUS.NOTFOUND);
}
});
}
return badges;
},
/**
* Get the DOI badge if this version has a DOI identifier.
* @returns {string} HTML string for the DOI badge, or an empty string if
* this version does not have a DOI.
*/
getDOIBadge() {
if (this.isDOI()) return this.createBadge(STATUS.DOI);
return "";
},
/**
* Render an inline note when the obsolescence chain order conflicts with
* dateUploaded chronology for this row.
* @returns {string} HTML string for the note, or an empty string.
*/
getDateNote() {
const dateNote = this.model.get("versionDateNote");
if (!dateNote) return "";
const { timeDiffMs } = dateNote;
const timeDiff = DateUtility.getRelativeDateString(timeDiffMs, 0, {
newerWord: "",
olderWord: "",
});
const classes = `${CLASS_NAMES.alert} ${CLASS_NAMES.alertInfo} ${CLASS_NAMES.dateNote}`;
return `
<div class="${classes}">${DATE_NOTE_MESSAGE(timeDiff)}</div>
`;
},
/**
* Helper method to create a badge HTML string based on the given status.
* @param {{label: string, className: string, description: string}} status
* Status object containing label, CSS class name, and description for the
* badge.
* @returns {string} HTML string for the badge.
*/
createBadge(status) {
if (!status) return "";
const { label, className, description } = status;
return `<span class="${CLASS_NAMES.badge} ${className}" title="${description}">${label}</span>`;
},
/** @inheritdoc */
render() {
this.removeTooltips();
this.applyVisibilityState();
if (this.model.get("hiddenByUI") === true) return this;
this.el.innerHTML = this.template(this.model);
if (this.isDOI()) {
this.el.classList.add(`${CLASS_NAMES.doi}`);
}
if (this.autoAddTooltips) {
this.addTooltips();
}
return this;
},
/**
* Add Semantic UI tooltips to badge elements based on their title
* attributes and the view's tooltipSettings.
*/
addTooltips() {
if (!this.tooltipSettings || !this.el.isConnected) {
return;
}
const badgeElements = this.el.querySelectorAll(`.${CLASS_NAMES.badge}`);
badgeElements.forEach((badge) => {
const title = badge.getAttribute("title");
const $badge = this.$(badge);
if (title) {
$badge.popup("destroy");
$badge.popup({
content: title,
...this.tooltipSettings,
});
} else {
$badge.popup("destroy");
}
});
},
/**
* Remove all Formantic popup instances from badge elements in this row.
*/
removeTooltips() {
const badgeElements = this.el.querySelectorAll(`.${CLASS_NAMES.badge}`);
badgeElements.forEach((badge) => {
this.$(badge).popup("destroy");
});
},
/**
* Swap the underlying DataONEObject instance and refresh bindings.
* @param {DataONEObject} newModel The new DataONEObject model.
*/
changeModel(newModel) {
if (this.model !== newModel) {
this.stopListening(this.model);
this.model = newModel;
this.listenTo(
this.model,
"change:hiddenByUI",
this.applyVisibilityState,
);
this.listenTo(this.model, "change", this.onModelChange);
this.render();
}
},
/**
* Clean up view resources before removal.
*/
onClose() {
this.removeTooltips();
},
/** @inheritdoc */
remove() {
this.onClose();
return Backbone.View.prototype.remove.call(this);
},
},
);
return ObjectVersionView;
});