"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",
"views/accordion/AccordionView",
"models/accordion/Accordion",
"semantic",
`!text!${MetacatUI.root}/css/mdq.css`,
], (
$,
_,
Backbone,
d3,
SolrResult,
DonutChart,
CitationView,
MdqRunTemplate,
LoadingTemplate,
QualityReport,
MarkdownView,
AccordionView,
AccordionModel,
_Semantic,
ReportCSS,
) => {
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: ";
const CLASS_NAMES = {
detailsContainer: "mdq-results",
detailsItem: "mdq-results__item",
detailsRootItem: "mdq-results__item--root",
detailsTitleOnly: "mdq-results__item--title-only",
detailsScrollable: "mdq-results__item--srollable",
};
// The maximum length of an item in the accordion before it is considered
// "long" and will be displayed as a collapsible item.
const MAX_ITEM_LENGTH = 500;
// A function to return the plural form of "check" based on the count
const PLURAL = (n) => (n === 1 ? "check" : "checks");
// Icons and strings for the categories in the detailed report. The order of
// categories will match the order of this array.
const REPORT_CATEGORIES = [
{
status: "GREEN",
icon: "check-sign",
buildTitle: ({ count, totalPassable }) =>
`Passed ${count} ${PLURAL(count)} out of ${totalPassable} (excluding informational checks).`,
},
{
status: "ORANGE",
icon: "exclamation",
buildTitle: ({ count }) =>
`Warning for ${count} ${PLURAL(count)}.${count ? " Please review these warnings." : ""}`,
},
{
status: "RED",
icon: "remove",
buildTitle: ({ count }) =>
`Failed ${count} ${PLURAL(count)}.${count ? " Please correct these issues." : ""}`,
},
{
status: "BLUE",
icon: "info-sign",
buildTitle: ({ count }) => `${count} informational ${PLURAL(count)}.`,
},
];
MetacatUI.appModel.addCSS(ReportCSS, "mdq");
/**
* @class MdqRunView
* @classdesc A view that fetches and displays a Dataset Assessment
* @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",
/**
* Settings passed to the Formantic UI popup module to configure a tooltip
* shown over item titles.
* @see https://fomantic-ui.com/modules/popup.html#/settings
* @type {object}
*/
tooltipSettings: {
position: "top center",
on: "hover",
variation: "tiny",
delay: {
show: 250,
hide: 40,
},
},
/** @inheritdoc */
initialize(options = {}) {
if (options.pid) {
this.pid = options.pid;
}
// Set up models for showing the detailed report in an accordion
this.accordionModel = new AccordionModel({ exclusive: true });
this.accordionItems = this.accordionModel.get("items");
},
/**
* 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 this;
}
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 });
return this;
},
/**
* 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 data = {
objectIdentifier: qualityReport.id,
suiteId: viewRef.suiteId,
suiteIdList: viewRef.suiteIdList,
suiteLabels: viewRef.suiteLabels,
timestamp: _.now(),
id: viewRef.pid,
groupedByType,
};
viewRef.$el.html(viewRef.template(data));
viewRef.insertBreadcrumbs();
viewRef.drawScoreChart(qualityReport.models, groupedResults);
viewRef.showCitation();
viewRef.show();
// TODO: Consider moving detail report to its own view
viewRef.renderDetailedReport(groupedResults);
},
/**
* Show each result and its outputs in a collapsible accordion grouped by
* result status (GREEN, ORANGE, RED, BLUE).
* @param {object} groupedResults - The results grouped by status
*/
async renderDetailedReport(groupedResults) {
const container = this.el.querySelector(
`.${CLASS_NAMES.detailsContainer}`,
);
if (this.accordionView) {
this.accordionView.remove();
}
if (this.accordionItems) {
this.accordionItems.reset();
}
container.innerHTML = "";
this.accordionView = new AccordionView({
model: this.accordionModel,
el: container,
tooltipSettings: this.tooltipSettings,
});
this.accordionView.render();
// Add items to the accordion as they are created
this.addResultItems(groupedResults);
// While results are being added to the accordion, query the names of
// any result outputs with identifiers. Show the title as the filename
// rather than the uuid.
const ids = await this.qualityReport.getAllOutputNames();
this.updateOutputIdTitles(ids);
this.stopListening(this.accordionItems, "add");
this.listenTo(this.accordionItems, "add", (item) =>
this.updateOutputIdTitles(ids, [item]),
);
},
/**
* Update the titles of items in the accordion that have an identifier
* in their output. The identifier will be used as the title of the item
* instead of the uuid.
* @param {object} ids - An object mapping identifiers to titles
* @param {Array} [models] - An optional array of models to update.
* If not provided, all models in the accordion will be updated.
* @since 2.34.0
*/
updateOutputIdTitles(ids, models) {
const toUpdate = models || this.accordionItems.models;
toUpdate.forEach((item) => {
const title = item.get("title");
if (title && ids[title]) {
item.set("title", ids[title]);
}
});
},
/**
* Add the result items to the accordion, including the categories (GREEN,
* ORANGE, RED, BLUE), and the individual results within each category,
* and their outputs.
* @param {object} groupedResults - The results grouped by status
* @since 2.34.0
*/
async addResultItems(groupedResults) {
const { qualityReport } = this;
// Generate text for each status (GREEN, ORANGE, RED, BLUE)
const counts = qualityReport.getCountsPerGroup(groupedResults);
let totalPassable = counts.total - (counts.blue || counts.BLUE || 0);
if (totalPassable < 0) totalPassable = 0;
REPORT_CATEGORIES.forEach((category) => {
const count = counts[category.status] || 0;
const categoryItem = {
status: category.status,
title: category.buildTitle({
count,
totalPassable,
}),
icon: category.icon,
};
this.addCategoryItem(categoryItem, groupedResults);
});
},
/**
* Add a category item to the accordion, which represents a
* category of checks (GREEN, ORANGE, RED, BLUE).
* @param {object} category - The category object containing status, title, and icon
* @param {object} groupedResults - The results grouped by status
* @since 2.34.0
*/
async addCategoryItem(category, groupedResults) {
// Root items are the main categories of checks, such as GREEN, ORANGE, RED, BLUE
const CN = CLASS_NAMES;
const { status, title, icon } = category;
const results = groupedResults[status] || [];
const statusClass = `${CN.detailsItem}--${status.toLowerCase()}`;
const rootClass = CN.detailsRootItem;
const baseClass = CN.detailsItem;
const classes = [baseClass, rootClass, statusClass];
const itemId = status.toLowerCase();
const item = { classes, title, itemId, icon };
// Add the item to the accordion items
this.accordionItems.add(item);
// Add an item to the accordion for each result in this category
results?.forEach((result) => this.addResultItem(result, item));
},
/**
* Calculate statistics for the outputs of a result.
* @param {Array} outputs - The outputs for a single result
* @returns {object} An object containing the total length of all outputs
* and the maximum length of a single output
* @since 2.34.0
*/
outputStats(outputs) {
let total = 0;
let max = 0;
outputs.forEach((o) => {
const len = o?.value?.length || 0;
total += len;
if (len > max) max = len;
});
const count = outputs.length;
return { total, max, count };
},
/**
* Add an item to the accordion for a result. There are three display
* options for the result, depending on the length of the outputs:
* 1. If any one output is very long, all outputs will be displayed as
* collapsible items in the accordion.
* 2. If the outputs combined are very short, the output will be
* displayed as the title of the item, and no content will be shown.
* 3. If the outputs combined are long, but not too long, the output
* will be displayed as the content of the item, and the item will be
* scrollable if the content is too long.
* @param {object} result - The result model containing the check and
* outputs
* @param {object} parentItem - The parent item in the accordion to which
* this item belongs.
* @since 2.34.0
*/
async addResultItem(result, parentItem) {
const CN = CLASS_NAMES;
const outputs = result.get("output") || [];
if (!outputs.length) return;
const check = result.get("check") || {};
const itemId = check.name;
const item = {
itemId,
title: check.name,
parent: parentItem.itemId,
icon: parentItem.icon,
classes: [CN.detailsItem],
description: this.getDescriptionHtml(result),
};
const { max, total, count } = this.outputStats(outputs);
if (max <= MAX_ITEM_LENGTH || count <= 1) {
// When the content is displayed without child elements, then limit
// the height of the content section.
item.classes.push(CN.detailsScrollable);
const content = await this.getOutputHTML(outputs);
if (total < MAX_ITEM_LENGTH) {
// Very short content => display as title, no content
item.title = content;
item.classes.push(CN.detailsTitleOnly);
} else {
// Long content => display as content collapsed under title
item.content = content;
}
this.accordionItems.add(item);
return;
}
this.accordionItems.add(item);
// Multiple & long outputs => create child items
outputs.forEach(async (output) => this.addOutputItem(output, item));
},
/**
* Add an output item to the accordion. This is used when there are
* multiple outputs that are too long to display as a single item.
* @param {object} output - The output object containing identifier, name,
* and value
* @param {object} parentItem - The parent item in the accordion to which
* this output belongs.
* @returns {Promise<object>} A promise that resolves with the output item
* @since 2.34.0
*/
async addOutputItem(output, parentItem) {
const content = await this.getOutputHTML([output]);
const outputItem = {
title:
output.identifier ||
output.name ||
`${content.substring(0, 100)}...`,
content,
parent: parentItem.itemId,
classes: [CLASS_NAMES.detailsItem],
icon: parentItem.icon,
};
this.accordionItems.add(outputItem);
},
/**
* 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);
});
});
},
/**
* Get the HTML description for a result to include in a tooltip in the
* detail report accordion.
* @param {object} result - The result model containing the check and
* outputs
* @returns {string} The HTML description for the result
* @since 2.34.0
*/
getDescriptionHtml(result) {
const status = result.get("status");
const check = result.get("check");
const description = check?.description || "";
const level = check?.level || "";
const type = check?.type || "";
const name = check?.name || "";
const labelClasses = {
REQUIRED: "inverse",
OPTIONAL: "warning",
FAILURE: "important",
SUCCESS: "success",
INFO: "info",
};
const statusClass = labelClasses[status] || "default";
const statusLabel = `<span class="label label-${statusClass}">${status}</span>`;
const levelClass = labelClasses[level] || "default";
const levelLabel = `<span class="label label-${levelClass}">${level}</span>`;
const typeLabel = `<span class="label">FAIR Type: <strong>${type}</strong></span>`;
const descriptionHtml = `
<div class="mdq-results__item-description text-left">
<div class="mdq-results__labels">${statusLabel} ${levelLabel} ${typeLabel}</div>
<h5><strong>${name}</strong></h5>
<div class=""><small>${description}</small></div>
</div>
`;
return descriptionHtml;
},
/**
* 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;
});