"use strict";
define([
"backbone",
"models/fileTable/FileItemActionViewModel",
"models/fileTable/FileItemViewModel",
"views/fileTable/FileItemActionView",
"views/fileTable/FileTableViewUtilities",
"semantic",
], (
Backbone,
FileItemActionViewModel,
FileItemViewModel,
FileItemActionView,
ViewUtilities,
) => {
const INDENT_SIZE = 30;
const CONTAINER_INDENT_OFFSET = 10;
const FILE_INDENT_OFFSET = 0;
const { classNames, escapeHtml, setOptionalAttribute } = ViewUtilities;
const CLASS_NAMES = {
actionCell: "controls file-actions",
bar: "bar",
badge: "badge",
button: "btn",
buttonGroup: "btn-group",
canRename: "canRename",
caret: "caret",
collapseControl: "d1package-collapse",
control: "control",
data: "data",
dropdownMenu: "dropdown-menu",
dropdownToggle: "dropdown-toggle",
disabled: "disabled",
editable: "editable",
expandControl: "d1package-expand",
file: "file",
fileActions: "file-actions",
fileHeader: "file-header",
fileTitle: "fileTitle",
folder: "folder",
hidden: "hidden",
iconCaretDown: "icon icon-caret-down",
iconCaretRight: "icon icon-caret-right",
iconFile: "icon-large icon-file",
iconFolderOpen: "icon-large icon-folder-open",
loading: "loading",
metricIcon: "catalog-metric-icon",
metricsCell: "metrics-count downloads",
name: "name",
progress: "progress progress-striped active",
removePreview: "remove-preview",
shareCell: "sharing",
sizeCell: "size",
statusCell: "status",
statusContent: "status-content",
tooltipHint: "file-tooltip-hint",
tooltipName: "file-tooltip-name",
tooltipPid: "file-tooltip-pid",
typeCell: "type",
typeIconCell: "type-icon",
view: "data-package-item",
};
const MESSAGES = {
collapse(label) {
return `Collapse ${label}`;
},
expand(label) {
return `Expand ${label}`;
},
moreActions: "More actions",
pidLabel: "PID",
renameHint: "Click to rename",
};
/**
* @class FileItemView
* @classdesc Generic file table row view. It renders view model state and
* emits UI events; it does not fetch, upload, download, or mutate files
* @classcategory Views/FileTable
* @since 2.39.0
* @screenshot views/fileTable/FileItemView.png
* @augments Backbone.View
*/
const FileItemView = Backbone.View.extend({
/** @inheritdoc */
tagName: "tr",
/** @inheritdoc */
className: CLASS_NAMES.view,
/** @inheritdoc */
events: {
[`focusout .${CLASS_NAMES.fileTitle}[contenteditable=true]`]:
"commitRename",
[`keydown .${CLASS_NAMES.fileTitle}[contenteditable=true]`]:
"handleRenameKeydown",
[`click .${CLASS_NAMES.buttonGroup} [data-action-id]`]:
"handleMenuActionClick",
"mouseenter [data-action-id='remove'], [data-id='remove']":
"showRemovePreview",
"mouseleave [data-action-id='remove'], [data-id='remove']":
"hideRemovePreview",
},
/** @inheritdoc */
initialize(options = {}) {
this.viewModel =
options.viewModel || options.model || new FileItemViewModel(options);
this.model = this.viewModel;
this.actionViews = [];
this.shareActionView = null;
this.skipRenameBlur = false;
this.listenTo(this.viewModel, "change", this.handleModelChange);
this.listenTo(this.viewModel, "change:isVisible", this.toggleVisibility);
this.listenTo(this.viewModel, "actions:update", this.render);
},
/**
* Inline ES6 template for the row contents.
* @param {object} data Render ready view model data
* @returns {string} Row cells
*/
template(data) {
const iconCell = data.showIconColumn ? this.typeIconTemplate(data) : "";
const metricsCell = data.showMetrics ? this.metricsTemplate(data) : "";
const shareCell = data.showShare ? this.shareTemplate() : "";
const statusCell = data.showStatus ? this.statusTemplate(data) : "";
const actionsCell = data.showActions ? this.actionsTemplate() : "";
const nameClass = classNames([
data.showIconColumn || data.isRenamable ? CLASS_NAMES.name : "",
data.isRenamable ? CLASS_NAMES.canRename : "",
]);
return `
${iconCell}
<td class="${nameClass}">
${this.nameTemplate(data)}
</td>
<td class="${CLASS_NAMES.sizeCell}">${escapeHtml(data.sizeLabel)}</td>
<td class="${CLASS_NAMES.typeCell}"${
data.typeTooltip
? ` data-tt-content="${escapeHtml(data.typeTooltip)}"`
: ""
}>${escapeHtml(data.typeLabel)}</td>
${metricsCell}
${shareCell}
${statusCell}
${actionsCell}
`;
},
/**
* Inline template for the row name.
* @param {object} data Render ready view model data
* @returns {string} File name markup
*/
nameTemplate(data) {
const tooltipHtml = this.nameTooltipHtml(data);
const tooltipAttr = tooltipHtml
? ` data-tt-html="${escapeHtml(tooltipHtml)}"`
: "";
if (data.showIconColumn) {
return `
<div
class="${classNames([
CLASS_NAMES.fileTitle,
data.isRenamable ? CLASS_NAMES.editable : "",
])}"
contenteditable="${data.isRenamable ? "true" : "false"}"
${tooltipAttr}
>${escapeHtml(data.label)}</div>
`;
}
const offset = data.isContainer
? CONTAINER_INDENT_OFFSET
: FILE_INDENT_OFFSET;
const indent =
Math.max(Number(data.level) || 0, 0) * INDENT_SIZE + offset;
const isExpandable = data.hasChildren || data.isExpandable;
const control = isExpandable ? this.expandControlTemplate(data) : "";
let iconControlClass = "";
if (isExpandable) {
iconControlClass = data.isExpanded
? CLASS_NAMES.collapseControl
: CLASS_NAMES.expandControl;
}
const iconClass = data.iconClass
? classNames([
data.iconClass,
iconControlClass,
isExpandable ? CLASS_NAMES.control : "",
])
: "";
const icon = iconClass ? `<i class="${escapeHtml(iconClass)}"></i>` : "";
const fileTitleClass = classNames([
CLASS_NAMES.fileTitle,
data.isRenamable ? CLASS_NAMES.editable : "",
]);
return `
<span style="padding-left:${indent}px">
${control}
${icon}
</span>
<span
class="${fileTitleClass}"
contenteditable="${data.isRenamable ? "true" : "false"}"
${tooltipAttr}
>${escapeHtml(data.label)}</span>
`;
},
/**
* Inline template for the editor icon column.
* @param {object} data Render ready view model data
* @returns {string} Icon cell
*/
typeIconTemplate(data) {
const isExpandable = data.hasChildren || data.isExpandable;
const control = isExpandable ? this.expandControlTemplate(data) : "";
const iconClass =
data.iconClass ||
(data.isContainer ? CLASS_NAMES.iconFolderOpen : CLASS_NAMES.iconFile);
let iconControlClass = "";
if (isExpandable) {
iconControlClass = data.isExpanded
? CLASS_NAMES.collapseControl
: CLASS_NAMES.expandControl;
}
const offset = data.isContainer
? CONTAINER_INDENT_OFFSET
: FILE_INDENT_OFFSET;
const indent =
Math.max(Number(data.level) || 0, 0) * INDENT_SIZE + offset;
const indentStyle = indent ? ` style="padding-left:${indent}px"` : "";
return `
<td class="${CLASS_NAMES.typeIconCell}">
<span${indentStyle}>
${control}
<i class="${escapeHtml(
classNames([
iconClass,
iconControlClass,
isExpandable ? CLASS_NAMES.control : "",
]),
)}"></i>
</span>
</td>
`;
},
/**
* Inline template for expand/collapse controls.
* @param {object} data Render ready view model data
* @returns {string} Expand/collapse controls
*/
expandControlTemplate(data) {
const expandStyle = data.isExpanded ? ' style="display:none"' : "";
const collapseStyle = data.isExpanded ? "" : ' style="display:none"';
return `
<a
class="${CLASS_NAMES.expandControl} ${CLASS_NAMES.control}"
href="#"
aria-label="${escapeHtml(MESSAGES.expand(data.label))}"
${expandStyle}
><i class="${CLASS_NAMES.iconCaretRight}"></i></a>
<a
class="${CLASS_NAMES.collapseControl} ${CLASS_NAMES.control}"
href="#"
aria-label="${escapeHtml(MESSAGES.collapse(data.label))}"
${collapseStyle}
><i class="${CLASS_NAMES.iconCaretDown}"></i></a>
`;
},
/**
* Inline template for the metrics cell.
* @param {object} data Render ready view model data
* @returns {string} Metrics cell
*/
metricsTemplate(data) {
const metric = data.metricLabel
? `
<span
class="${CLASS_NAMES.badge}"
data-tt-content="${escapeHtml(data.metricTitle)}"
>
<i class="${CLASS_NAMES.metricIcon} ${escapeHtml(
data.metricIconClass,
)}"></i>
${escapeHtml(data.metricLabel)}
</span>
`
: "";
return `
<td class="${CLASS_NAMES.metricsCell}" data-id="${escapeHtml(data.id)}">
${metric}
</td>
`;
},
/**
* Inline template for the status cell.
* @param {object} data Render ready view model data
* @returns {string} Status cell
*/
statusTemplate(data) {
const status = data.status || {};
const statusClass = classNames([
CLASS_NAMES.statusContent,
status.className,
]);
const isProgressStatus = Number.isFinite(status.progress);
const tooltip = status.title
? ` data-tt-content="${escapeHtml(status.title)}"`
: "";
const icon =
!isProgressStatus && status.iconClass
? `<i class="${escapeHtml(status.iconClass)}"></i>`
: "";
const label =
!isProgressStatus && status.label
? `<span>${escapeHtml(status.label)}</span>`
: "";
const progress = isProgressStatus
? `
<div class="${CLASS_NAMES.progress}">
<div
class="${CLASS_NAMES.bar}"
style="width:${Math.max(0, Math.min(100, status.progress))}%"
></div>
</div>
`
: "";
return `
<td class="${CLASS_NAMES.statusCell}">
<div class="${escapeHtml(statusClass)}"${tooltip}>
${icon}
${label}
${progress}
</div>
</td>
`;
},
/** @returns {string} Actions cell placeholder */
actionsTemplate() {
return `<td class="${CLASS_NAMES.actionCell}"></td>`;
},
/** @returns {string} Share cell placeholder */
shareTemplate() {
return `<td class="${CLASS_NAMES.shareCell}"></td>`;
},
/**
* @inheritdoc
* @returns {FileItemView} This view
*/
render() {
const data = this.viewModel.toRenderData();
this.closeActionViews();
this.closeShareActionView();
this.destroyTooltips();
this.$el
.attr("class", this.getRowClassName(data))
.css("display", data.isVisible ? "" : "none")
.html(this.template(data));
setOptionalAttribute(this.$el, "data-id", data.id);
setOptionalAttribute(this.$el, "data-parent-id", data.parentId);
setOptionalAttribute(this.$el, "data-collection-id", data.collectionId);
setOptionalAttribute(
this.$el,
"data-category",
data.id ? `entities-${data.id}` : "",
);
setOptionalAttribute(this.$el, "data-packageid", data.collectionId);
this.$el.data({
view: this,
viewModel: this.viewModel,
});
this.renderShareAction(data);
this.renderActions(data);
this.trigger("renderComplete", this);
return this;
},
/**
* Refresh row markup unless only row visibility changed.
* @returns {FileItemView} This view
*/
handleModelChange() {
const changedKeys = Object.keys(this.viewModel.changedAttributes() || {});
if (changedKeys.length === 1 && changedKeys[0] === "isVisible") {
return this;
}
return this.render();
},
/**
* Show or hide this row without rebuilding its cells.
* @returns {FileItemView} This view
*/
toggleVisibility() {
this.$el.css("display", this.viewModel.get("isVisible") ? "" : "none");
return this;
},
/** Preview the row that will be removed */
showRemovePreview() {
this.$el.addClass(CLASS_NAMES.removePreview);
},
/** Clear the row removal preview */
hideRemovePreview() {
this.$el.removeClass(CLASS_NAMES.removePreview);
},
/**
* HTML for the file name tooltip: the full display name and, when known,
* the object PID (plus a rename hint for editable rows).
* @param {object} data Render ready view model data
* @returns {string} Tooltip HTML, or "" when there is no name
*/
nameTooltipHtml(data) {
const name = escapeHtml(data.titleTooltip || data.label || "");
if (!name) return "";
const pid = data.pid
? `<div class="${CLASS_NAMES.tooltipPid}">${MESSAGES.pidLabel}: ${escapeHtml(data.pid)}</div>`
: "";
const rename = data.isRenamable
? `<div class="${CLASS_NAMES.tooltipHint}">${MESSAGES.renameHint}</div>`
: "";
return `<div class="${CLASS_NAMES.tooltipName}">${name}</div>${pid}${rename}`;
},
/** Destroy any Fomantic popups attached to this row's controls */
destroyTooltips() {
this.$("[data-tt-initialized]")
.popup("destroy")
.removeAttr("data-tt-initialized");
},
/**
* CSS classes for the row element.
* @param {object} data Render ready view model data
* @returns {string} Class string
*/
getRowClassName(data) {
const isProgressStatus = Number.isFinite(data.status?.progress);
return classNames([
data.isContainer && data.level === 0 ? CLASS_NAMES.fileHeader : "",
CLASS_NAMES.view,
data.isContainer ? CLASS_NAMES.folder : CLASS_NAMES.file,
data.isContainer ? "" : CLASS_NAMES.data,
isProgressStatus ? CLASS_NAMES.loading : "",
data.className,
]);
},
/**
* Render the optional share action.
* @param {object} data Render ready view model data
*/
renderShareAction(data) {
if (!data.showShare || !data.shareAction) return;
this.shareActionView = new FileItemActionView({
viewModel: data.shareAction,
});
this.listenTo(
this.shareActionView,
"action:click",
this.handleActionClick,
);
this.$(`.${CLASS_NAMES.shareCell}`).append(
this.shareActionView.render().el,
);
},
/**
* Render configured row actions.
* @param {object} data Render ready view model data
*/
renderActions(data) {
if (!data.showActions) return;
const container = this.$(
`.${CLASS_NAMES.actionCell.split(" ").join(", .")}`,
).first();
data.actions.each((actionModel) => {
if (actionModel.get("menuItems")) {
container.append(this.actionMenuTemplate(actionModel));
return;
}
const actionView = new FileItemActionView({ viewModel: actionModel });
this.actionViews.push(actionView);
this.listenTo(actionView, "action:click", this.handleActionClick);
container.append(actionView.render().el);
});
},
/**
* Render a primary action with a dropdown menu of related actions.
* @param {FileItemActionViewModel} actionModel Primary action
* @returns {string} Action menu markup
*/
actionMenuTemplate(actionModel) {
const data = actionModel.toRenderData();
const menuItems = data.menuItems || [];
const items = menuItems
.map((item) => {
const itemClass = classNames([
item.isVisible === false ? CLASS_NAMES.hidden : "",
item.isDisabled ? CLASS_NAMES.disabled : "",
]);
const itemAriaDisabled = item.isDisabled
? ' aria-disabled="true"'
: "";
return `
<li>
<a
href="#"
class="${itemClass}"
data-action-id="${escapeHtml(item.id)}"
data-tt-content="${escapeHtml(item.title || item.label)}"
${itemAriaDisabled}
>${escapeHtml(item.label)}</a>
</li>
`;
})
.join("");
const groupClass = classNames([
CLASS_NAMES.buttonGroup,
data.isVisible ? "" : CLASS_NAMES.hidden,
]);
const disabled = data.isDisabled ? ' disabled aria-disabled="true"' : "";
return `
<div
class="${groupClass}"
data-root-action-id="${escapeHtml(data.id)}"
>
<button
type="button"
class="${escapeHtml(data.className)}"
data-action-id="${escapeHtml(data.id)}"
data-tt-content="${escapeHtml(data.title)}"
aria-label="${escapeHtml(data.ariaLabel)}"
${disabled}
>${escapeHtml(data.label)}</button>
<button
type="button"
class="${CLASS_NAMES.button} ${CLASS_NAMES.dropdownToggle}"
data-toggle="dropdown"
data-tt-content="${MESSAGES.moreActions}"
aria-label="${MESSAGES.moreActions}"
${disabled}
><span class="${CLASS_NAMES.caret}"></span></button>
<ul class="${CLASS_NAMES.dropdownMenu}" role="menu">
${items}
</ul>
</div>
`;
},
/**
* Emit action clicks from a grouped action menu.
* @param {Event} event Click event
* @returns {boolean} False to stop native link/button behavior
*/
handleMenuActionClick(event) {
event.preventDefault();
event.stopPropagation();
const actionId = this.$(event.currentTarget).attr("data-action-id");
const rootId = this.$(event.currentTarget)
.closest(`.${CLASS_NAMES.buttonGroup}`)
.attr("data-root-action-id");
const rootAction = this.viewModel.getActions().findWhere({ id: rootId });
const actionData = this.getMenuActionData(rootAction, actionId);
if (
!rootAction?.isEnabled() ||
!actionData ||
actionData.isVisible === false ||
actionData.isDisabled
) {
return false;
}
this.handleActionClick(new FileItemActionViewModel(actionData), event);
return false;
},
/**
* Find the action descriptor represented by a grouped action click.
* @param {FileItemActionViewModel} rootAction Root grouped action
* @param {string} actionId Requested action id
* @returns {object|null} Action state
*/
getMenuActionData(rootAction, actionId) {
if (!rootAction || !actionId) return null;
const rootData = rootAction.toJSON();
if (rootData.id === actionId) return rootData;
return (
(rootData.menuItems || []).find((item) => item.id === actionId) || null
);
},
/**
* Bubble action clicks to the table view.
* @param {FileItemActionViewModel} actionModel Action view model
* @param {Event} event Click event
*/
handleActionClick(actionModel, event) {
this.trigger("action:click", this.viewModel, actionModel, event);
},
/**
* Emit a proposed label after inline editing.
* @param {Event} event Focusout event
*/
commitRename(event) {
if (this.skipRenameBlur) {
this.skipRenameBlur = false;
return;
}
const proposedLabel = this.$(event.currentTarget).text();
if (proposedLabel !== this.viewModel.getDisplayLabel()) {
this.trigger("rename:commit", this.viewModel, proposedLabel, event);
}
},
/**
* Commit or cancel inline editing keyboard actions.
* @param {Event} event Keyboard event
*/
handleRenameKeydown(event) {
if (event.key === "Enter") {
event.preventDefault();
const proposedLabel = this.$(event.currentTarget).text();
this.skipRenameBlur = true;
event.currentTarget.blur();
this.skipRenameBlur = false;
if (proposedLabel !== this.viewModel.getDisplayLabel()) {
this.trigger("rename:commit", this.viewModel, proposedLabel, event);
}
} else if (event.key === "Escape") {
event.preventDefault();
this.$(event.currentTarget).text(this.viewModel.getDisplayLabel());
this.skipRenameBlur = true;
event.currentTarget.blur();
this.skipRenameBlur = false;
}
},
/** Close action subviews */
closeActionViews() {
this.actionViews.forEach((actionView) => {
this.stopListening(actionView);
actionView.remove();
});
this.actionViews = [];
},
/** Close the share action subview */
closeShareActionView() {
if (!this.shareActionView) return;
this.stopListening(this.shareActionView);
this.shareActionView.remove();
this.shareActionView = null;
},
/**
* Close subviews before removing this view.
* @returns {FileItemView} This view
*/
remove() {
this.destroyTooltips();
this.closeActionViews();
this.closeShareActionView();
return Backbone.View.prototype.remove.call(this);
},
/** Close this view when the parent table view is closed. */
onClose() {
this.remove();
},
});
return FileItemView;
});