"use strict";
define([
"jquery",
"underscore",
"backbone",
"d3",
"models/SolrResult",
"DonutChart",
"views/CitationView",
"text!templates/mdqRun.html",
"text!templates/loading-metrics.html",
"collections/QualityReport",
"views/MarkdownView",
], (
$,
_,
Backbone,
d3,
SolrResult,
DonutChart,
CitationView,
MdqRunTemplate,
LoadingTemplate,
QualityReport,
MarkdownView,
) => {
const MSG_ERROR_GENERATING_REPORT =
"There was an error generating the assessment report.";
const MSG_QUEUED_REPORT =
"The assessment report is in the Assessment Server queue to be generated.";
const MSG_REPORT_NOT_READY =
"The assessment report for this dataset is not ready yet. Try checking back in 24 hours to see these results.";
const MSG_ERROR_GENERAL =
"There was an error retrieving the assessment report for this dataset.";
const MSG_ERROR_DETAILS = "The Assessment Server reported this error: ";
const QUEUE_ERROR_DETAILS = " It was queued at: ";
/**
* @class MdqRunView
* @classdesc A view that fetches and displays a Metadata Assessment Report
* @classcategory Views
* @name MdqRunView
* @augments Backbone.View
* @constructs
*/
const MdqRunView = Backbone.View.extend(
/** @lends MdqRunView.prototype */ {
/** @inheritdoc */
el: "#Content",
/** @inheritdoc */
events: {
"change #suiteId": "switchSuite",
},
/**
* The identifier of the object to be assessed
* @type {string}
*/
pid: null,
/**
* The currently selected/requested suite
* @type {string}
*/
suiteId: null,
/**
* The list of all potential suites for this theme
* @type {string[]}
*/
suiteIdList: [],
/**
* The template to use to indicate that the view is loading
* @type {Function}
*/
loadingTemplate: _.template(LoadingTemplate),
/**
* The main template for this view
* @type {Function}
*/
template: _.template(MdqRunTemplate),
/**
* The selector for the element that will contain the breadcrumbs
* @type {string}
*/
breadcrumbContainer: "#breadcrumb-container",
/**
* A JQuery selector for the element in the template that will contain the loading
* image
* @type {string}
* @since 2.15.0
*/
loadingContainer: "#mdqResult",
/**
* Handles the event when the user selects a different suite
* @param {Event} event The event object
* @returns {boolean} False, to prevent the default action
*/
switchSuite(event) {
const select = $(event.target);
const suiteId = $(select).val();
MetacatUI.uiRouter.navigate(
`quality/s=${suiteId}/${encodeURIComponent(this.pid)}`,
{ trigger: false },
);
this.suiteId = suiteId;
this.render();
return false;
},
/** @inheritdoc */
render() {
const viewRef = this;
// The suite use for rendering can initially be set via the theme AppModel.
// If a suite id is request via the metacatui route, then we have to display that
// suite, and in addition have to display all possible suites for this theme in
// a selection list, if the user wants to view a different one.
this.suiteIdList = MetacatUI.appModel.get("mdqSuiteIds");
if (!this.suiteId) {
this.suiteId = this.suiteIdList?.[0];
}
this.suiteLabels = MetacatUI.appModel.get("mdqSuiteLabels");
// Insert the basic template
this.$el.html(this.template({}));
// Show breadcrumbs leading back to the dataset & data search
this.insertBreadcrumbs();
// Insert the loading image
this.showLoading();
if (!this.pid) {
const searchLink = $(document.createElement("a"))
.attr("href", `${MetacatUI.root}/data`)
.text("Search our database");
const message = $(document.createElement("span"))
.text(" to see an assessment report for a dataset")
.prepend(searchLink);
this.showMessage(message, true, false);
return;
}
const root = MetacatUI.appModel.get("mdqRunsServiceUrl");
const qualityUrl = `${root}${viewRef.suiteId}/${viewRef.pid}`;
const qualityReport = new QualityReport([], {
url: qualityUrl,
pid: viewRef.pid,
});
this.qualityReport = qualityReport;
this.listenToOnce(
qualityReport,
"fetchError",
this.handleQualityReportError,
);
this.listenToOnce(
qualityReport,
"fetchComplete",
this.renderQualityReport,
);
qualityReport.fetch({ url: qualityUrl });
},
/**
* Render the quality report once it has been fetched
*/
async renderQualityReport() {
const viewRef = this;
const { qualityReport } = this;
if (qualityReport?.runStatus?.toUpperCase() !== "SUCCESS") {
this.handleQualityReportError();
return;
}
viewRef.hideLoading();
// Filter out the checks with level 'METADATA', as these checks are intended
// to pass info to metadig-engine indexing (for search, faceting), and not intended for display.
qualityReport.reset(
_.reject(qualityReport.models, (model) => {
const check = model.get("check");
if (check.level === "METADATA") {
return true;
}
return false;
}),
);
const groupedResults = qualityReport.groupResults(qualityReport.models);
const groupedByType = qualityReport.groupByType(qualityReport.models);
const checkCount = qualityReport.length;
const blueCount = groupedResults.BLUE?.length || 0;
const greenCount = groupedResults.GREEN?.length || 0;
const orangeCount = groupedResults.ORANGE?.length || 0;
const redCount = groupedResults.RED?.length || 0;
const extraRedText =
redCount > 0 ? " Please correct these issues." : "";
const extraOrangeText =
orangeCount > 0 ? " Please review these warnings." : "";
const totalPassable = checkCount - blueCount;
const checkWord = (num) => (num === 1 ? "check" : "checks");
const greenText = `Passed ${greenCount} ${checkWord(greenCount)} out of ${totalPassable} (excluding informational checks).`;
const orangeText = `Warning for ${orangeCount} ${checkWord(orangeCount)}. ${extraOrangeText}`;
const redText = `Failed ${redCount} ${checkWord(redCount)}. ${extraRedText}`;
const blueText = `${blueCount} informational ${checkWord(blueCount)}.`;
const data = {
objectIdentifier: qualityReport.id,
suiteId: viewRef.suiteId,
suiteIdList: viewRef.suiteIdList,
suiteLabels: viewRef.suiteLabels,
timestamp: _.now(),
id: viewRef.pid,
groupedResults,
groupedByType,
checkCount,
greenText,
orangeText,
redText,
blueText,
};
viewRef.$el.html(viewRef.template(data));
await viewRef.addCheckItems(groupedResults);
viewRef.insertBreadcrumbs();
viewRef.drawScoreChart(qualityReport.models, groupedResults);
viewRef.showCitation();
viewRef.show();
// Make sure the DOM is updated before initializing the popover
requestAnimationFrame(() => {
viewRef.$(".popover-this").popover();
});
},
/**
* Add the check result item els to the view
* @param {object} groupedResults - The results grouped by status
* @since 2.31.0
*/
async addCheckItems(groupedResults) {
const viewRef = this;
const types = {
GREEN: {
className: "pass",
iconClass: "icon-check-sign success",
headerClass: "success",
},
ORANGE: {
className: "warn",
iconClass: "icon-exclamation",
headerClass: "warning",
},
RED: {
className: "fail",
iconClass: "icon-remove",
headerClass: "danger",
},
BLUE: {
className: "info-check",
iconClass: "icon-info",
headerClass: "info",
},
};
Object.keys(types).forEach(async (type) => {
const { className, iconClass, headerClass } = types[type];
const results = groupedResults[type];
if (results) {
// Use `map` to handle promises
const itemEls = await Promise.all(
results.map(async (result) =>
viewRef.createCheckItem(result, className, iconClass),
),
);
// Join the resolved HTML strings and append them
viewRef
.$(`.list-group-item.${headerClass}`)
.after(itemEls.join(""));
}
});
},
/**
* Create a check item element
* @param {object} result - The check result
* @param {string} className - The class name for the check item
* @param {string} iconClass - The class
* @returns {string} The HTML for the check item
* @since 2.31.0
*/
async createCheckItem(result, className, iconClass) {
const outputs = await this.getOutputHTML(result.get("output"));
return `
<li class="list-group-item check ${className} collapse row-fluid">
<span class="icon-stack span1">
<i class="${iconClass}"></i>
</span>
<span class="span6">${outputs}</span>
<span class="span1">
<a tabindex="0"
role="button"
class="popover-this"
data-container="body"
data-trigger="hover focus"
data-html="true"
data-title="${result.get("check").name}"
data-content="${result.get("check").description}">
<i class="icon icon-question-sign subtle"></i>
</a>
</span>
<span class="span4">
<span class="badge pull-right">${result.get("status")}</span>
<span class="badge pull-right">${result.get("check").level}</span>
<span class="badge pull-right">${result.get("check").type}</span>
</span>
</li>
`;
},
/**
* Get the HTML for the output
* @param {Array} outputs - The outputs from the quality service
* @returns {string} The HTML for the output
*/
async getOutputHTML(outputs) {
const outputHTMLs = await Promise.all(
outputs.map(async (output) => {
if (output?.type?.includes("image")) {
return `<img src="data:${output.type};base64,${output.value}" />`;
}
if (output.type === "markdown") {
return this.getHTMLFromMarkdown(output.value);
}
return `<div class="check-output">${output.value}</div>`;
}),
);
return outputHTMLs.join("");
},
/**
* Get the HTML from markdown
* @param {string} markdown - The markdown to convert to HTML
* @returns {Promise} A promise that resolves with the HTML
*/
getHTMLFromMarkdown(markdown) {
const markdownView = new MarkdownView({
markdown,
showTOC: false,
}).render();
return new Promise((resolve) => {
this.listenToOnce(markdownView, "mdRendered", () => {
resolve(markdownView.el.innerHTML);
});
});
},
/**
* Handles errors that occur when fetching the quality report
*/
handleQualityReportError() {
const { qualityReport } = this;
let status =
qualityReport.runStatus || qualityReport.fetchResponse?.status;
if (typeof status === "string") {
status = status.toUpperCase();
}
const description =
qualityReport.errorDescription ||
qualityReport.fetchResponse?.statusText ||
"";
const time = qualityReport.timestamp;
const errorReport = description
? `${MSG_ERROR_DETAILS}${description}`
: "";
const queueTime = time ? `${QUEUE_ERROR_DETAILS} ${time}` : "";
let msgText = "";
if (status === "FAILURE") {
msgText = `${MSG_ERROR_GENERATING_REPORT}`;
if (errorReport) {
msgText += ` ${errorReport}`;
}
} else if (status === "QUEUED" || status === "PROCESSING") {
msgText = `${MSG_QUEUED_REPORT} `;
if (queueTime) {
msgText += ` ${queueTime}`;
}
} else if (status === 404) {
msgText = MSG_REPORT_NOT_READY;
} else {
msgText = MSG_ERROR_GENERAL;
if (errorReport) {
msgText += ` ${errorReport}`;
}
}
this.showMessage(msgText);
},
/**
* Updates the message in the loading image
* @param {string} message The new message to display
* @param {boolean} [showHelp] If set to true, and an email contact is configured
* in MetacatUI, then the contact email will be shown at the bottom of the message.
* @param {boolean} [showLink] If set to true, a link back to the dataset will be
* appended to the end of the message.
* @since 2.15.0
*/
showMessage(message, showHelp = true, showLink = true) {
const view = this;
const messageEl = this.loadingEl.find(".message");
if (!messageEl) {
return;
}
// Update the message
messageEl.html(message);
// Create a link back to the data set
if (showLink) {
const viewURL = `/view/${encodeURIComponent(this.pid)}`;
const backLink = $(document.createElement("a")).text(
" Return to the dataset",
);
backLink.on("click", () => {
view.hideLoading();
MetacatUI.uiRouter.navigate(viewURL, {
trigger: true,
replace: true,
});
});
messageEl.append(backLink);
}
// Show how the user can get more help
if (showHelp) {
const emailAddress = MetacatUI.appModel.get("emailContact");
// Don't show help if there's no contact email configured
if (emailAddress) {
const helpEl = $(
"<p class='webmaster-email' style='margin-top:20px'>" +
"<i class='icon-envelope-alt icon icon-on-left'></i>" +
"Need help? Email us at </p>",
);
const emailLink = $(document.createElement("a"))
.attr("href", `mailto:${emailAddress}`)
.text(emailAddress);
helpEl.append(emailLink);
messageEl.append(helpEl);
}
}
},
/**
* Render a loading image with message
*/
showLoading() {
const loadingEl = this.loadingTemplate({
message: "Retrieving assessment report...",
character: "none",
type: "barchart",
});
this.loadingEl = $(loadingEl);
this.$el.find(this.loadingContainer).html(this.loadingEl);
},
/**
* Remove the loading image and message.
*/
hideLoading() {
this.loadingEl.remove();
},
/** Render a citation view for the object and display it in the view */
showCitation() {
const solrResultModel = new SolrResult({
id: this.pid,
});
this.listenTo(solrResultModel, "sync", () => {
const citationView = new CitationView({
model: solrResultModel,
createLink: false,
createTitleLink: true,
});
citationView.render();
this.$("#mdqCitation").prepend(citationView.el);
});
solrResultModel.getInfo();
},
/** Show the view */
show() {
this.$el.hide();
this.$el.fadeIn({ duration: "slow" });
},
/**
* Draw a donut chart showing the distribution of checks by status
* @param {Array} results - The array of check results
* @param {object} groupedResults - The results grouped by status
*/
drawScoreChart(results, groupedResults) {
const dataCount = results.length;
const data = [
{
label: "Pass",
count: groupedResults.GREEN.length,
perc: groupedResults.GREEN.length / results.length,
},
{
label: "Warn",
count: groupedResults.ORANGE.length,
perc: groupedResults.ORANGE.length / results.length,
},
{
label: "Fail",
count: groupedResults.RED.length,
perc: groupedResults.RED.length / results.length,
},
{
label: "Info",
count: groupedResults.BLUE.length,
perc: groupedResults.BLUE.length / results.length,
},
];
const svgClass = "data";
// If d3 isn't supported in this browser or didn't load correctly, insert a text title instead
if (!d3) {
this.$(".format-charts-data").html(
`<h2 class='${svgClass} fallback'>${MetacatUI.appView.commaSeparateNumber(
dataCount,
)} data files</h2>`,
);
return;
}
// Draw a donut chart
const donut = new DonutChart({
id: "data-chart",
data,
total: dataCount,
titleText: "checks",
titleCount: dataCount,
svgClass,
countClass: "data",
height: 250,
width: 250,
keepOrder: true,
formatLabel(name) {
return name;
},
});
this.$(".format-charts-data").html(donut.render().el);
},
/**
* Insert breadcrumbs into the view
*/
insertBreadcrumbs() {
const encodedPid = encodeURIComponent(this.pid);
const root = MetacatUI.root || "/";
const breadcrumbs = `
<ol class="breadcrumb">
<li class="home"><a href="${root || "/"}" class="home">Home</a></li>
<li class="search"><a href="${root}/data" class="search">Search</a></li>
<li class="inactive"><a href="${root}/view/${encodedPid}" class="inactive">Metadata</a></li>
<li class="inactive"><a href="${root}/quality/${encodedPid}" class="inactive">Assessment Report</a></li>
</ol>
`;
this.el.querySelector(this.breadcrumbContainer).innerHTML = breadcrumbs;
},
},
);
return MdqRunView;
});