"use strict";
define([
"backbone",
"models/fileTable/FileTableViewModel",
"views/fileTable/FileItemActionView",
"views/fileTable/FileItemView",
"views/fileTable/FileTableViewUtilities",
], (
Backbone,
FileTableViewModel,
FileItemActionView,
FileItemView,
ViewUtilities,
) => {
const { escapeHtml, setOptionalAttribute, TOOLTIP_SETTINGS } = ViewUtilities;
const CLASS_NAMES = {
actionPlaceholder: "empty-action",
body: "data-package-file-table",
buttonGroup: "btn-group",
center: "center",
disabled: "disabled",
dropTarget: "data-package-drop-target",
dropdown: "dropdown",
dropdownMenu: "dropdown-menu",
collapseControl: "d1package-collapse",
editable: "editable",
expandControl: "d1package-expand",
fileHeader: "file-header",
fileTitle: "fileTitle",
filterButton: "btn data-package-filter-control",
filterIcon: "icon icon-large icon-filter",
message: "message",
messageRow: "data-package-message-row",
notice: "file-listing-note",
noticeAction: "file-listing-note-action",
noticeRow: "file-listing-note-row",
open: "open",
sortIcon: "icon icon-solid icon-arrow-up",
subtle: "subtle",
table: "table table-striped table-hover download-contents table-condensed",
tableDisabled: "file-table-disabled",
tableHeader: "table-header",
tableHeaderTitle: "table-header-title",
};
const IDS = {
actionsColumn: "data-package-table-actions",
body: "data-package-table-body",
buttonColumn: "data-package-table-button",
fileColumn: "data-package-table-files",
foot: "data-package-table-foot",
filterControl: "data-package-filter-control",
guideStatusColumn: "data-package-table-guide-status",
head: "data-package-table-head",
iconColumn: "data-package-table-icon",
metricsColumn: "data-package-table-metrics",
shareColumn: "data-package-table-share",
sizeColumn: "data-package-table-size",
table: "data-package-table",
typeColumn: "data-package-table-type",
};
const MESSAGES = {
filter: "Filter",
filterUnavailable: "Package table filtering is not yet available",
};
const TOOLTIP_TIMER_KEYS = {
show: "fileTableTooltipShowTimer",
hide: "fileTableTooltipHideTimer",
};
const clearTooltipTimer = (target, key) => {
const timer = target.data(key);
if (!timer) return;
clearTimeout(timer);
target.removeData(key);
};
/**
* @class FileTableView
* @classdesc Generic file table view. It renders table state from a view
* model and delegates row rendering to FileItemView
* @classcategory Views/FileTable
* @since 2.39.0
* @screenshot views/fileTable/FileTableView.png
* @augments Backbone.View
*/
const FileTableView = Backbone.View.extend({
tagName: "table",
className: CLASS_NAMES.table,
events: {
[`click .${CLASS_NAMES.expandControl}`]: "expandRow",
[`click .${CLASS_NAMES.collapseControl}`]: "collapseRow",
"click [data-toggle='dropdown']": "blockDisabledInteraction",
[`click .${CLASS_NAMES.dropdownMenu} a`]: "blockDisabledInteraction",
[`click .${CLASS_NAMES.fileTitle}.${CLASS_NAMES.editable}`]:
"blockDisabledInteraction",
"mouseenter [data-tt-content], [data-tt-html]": "showLazyTooltip",
"focusin [data-tt-content], [data-tt-html]": "showLazyTooltip",
"mouseleave [data-tt-content], [data-tt-html]": "hideLazyTooltip",
"focusout [data-tt-content], [data-tt-html]": "hideLazyTooltip",
[`click .${CLASS_NAMES.noticeAction}`]: "handleNoticeAction",
dragenter: "showDropTarget",
dragover: "showDropTarget",
dragleave: "hideDropTarget",
drop: "handleFilesDrop",
},
/** @inheritdoc */
initialize(options = {}) {
this.viewModel =
options.viewModel || options.model || new FileTableViewModel(options);
this.model = this.viewModel;
this.subviews = {};
this.emptyActionView = null;
this.isDisabled = options.disabled === true;
this.listenTo(this.viewModel, "change", this.render);
this.listenTo(this.viewModel, "rows:update", this.renderRows);
},
/**
* Inline ES6 template for the table structure.
* @param {object} data Render ready view model data
* @returns {string} Table markup
*/
template(data) {
const subtitle = data.subtitle
? ` <span class="${CLASS_NAMES.subtle}">${escapeHtml(data.subtitle)}</span>`
: "";
const titleRow = data.showTitle
? `
<tr class="${CLASS_NAMES.tableHeader}">
<th colspan="${data.columnCount}" class="${CLASS_NAMES.tableHeader}">
<span class="${CLASS_NAMES.tableHeaderTitle}">${escapeHtml(data.title)}</span>${subtitle}
</th>
</tr>
`
: "";
const headerRow = data.showHeader ? this.headerTemplate(data) : "";
const noticeRow = data.noticeMessage ? this.noticeTemplate(data) : "";
const head =
titleRow || headerRow
? `
<thead id="${IDS.head}">
${titleRow}
${headerRow}
</thead>
`
: "";
const foot = noticeRow
? `
<tfoot id="${IDS.foot}">
${noticeRow}
</tfoot>
`
: "";
return `
${head}
<tbody
id="${escapeHtml(data.bodyId || IDS.body)}"
class="${CLASS_NAMES.body}"
></tbody>
${foot}
`;
},
/**
* Inline template for a quiet table level notice.
* @param {object} data Render ready view model data
* @returns {string} Notice row markup
*/
noticeTemplate(data) {
const icon = data.noticeIconClass
? `<i class="${escapeHtml(data.noticeIconClass)}" aria-hidden="true"></i>`
: "";
const action =
data.noticeActionId && data.noticeActionLabel
? `
<button
type="button"
class="${escapeHtml(
`${CLASS_NAMES.noticeAction} ${data.noticeActionClassName || "btn"}`,
)}"
data-notice-action-id="${escapeHtml(data.noticeActionId)}"
>
${escapeHtml(data.noticeActionLabel)}
</button>
`
: "";
return `
<tr class="${CLASS_NAMES.noticeRow}">
<td colspan="${data.columnCount}">
<div class="${escapeHtml(
data.noticeClassName || CLASS_NAMES.notice,
)}" role="note">
${icon}
<span>${escapeHtml(data.noticeMessage)}</span>
${action}
<span class="file-listing-note-status" aria-live="polite"></span>
</div>
</td>
</tr>
`;
},
/**
* Inline ES6 template for the column header row.
* @param {object} data Render ready view model data
* @returns {string} Header row markup
*/
headerTemplate(data) {
const sortIcon = data.showSortingControl
? ` <i class="${CLASS_NAMES.sortIcon}" aria-hidden="true"></i>`
: "";
const filterButton = data.showFilteringControl
? `
<button
type="button"
class="${CLASS_NAMES.filterButton}"
data-id="${escapeHtml(data.id)}"
disabled
title="${MESSAGES.filterUnavailable}"
>
<i class="${CLASS_NAMES.filterIcon}" aria-hidden="true"></i>
${MESSAGES.filter}
</button>
`
: "";
const iconHeader = data.showIconColumn
? `<th id="${IDS.iconColumn}"></th>`
: "";
const metricsHeader = data.showMetrics
? `<th id="${IDS.metricsColumn}" style="width:10%">${escapeHtml(
data.metricsColumnLabel,
)}${sortIcon}</th>`
: "";
const shareHeader = data.showShare
? `<th id="${IDS.shareColumn}">${escapeHtml(
data.shareColumnLabel,
)}</th>`
: "";
const statusHeader = data.showStatus
? `<th id="${IDS.guideStatusColumn}">${escapeHtml(
data.statusColumnLabel,
)}</th>`
: "";
const actionsHeader = data.showActions
? `<th id="${
data.showIconColumn ? IDS.buttonColumn : IDS.actionsColumn
}" ${
data.showIconColumn ? "" : 'style="width:10%"'
}>${escapeHtml(data.actionsColumnLabel)}</th>`
: "";
return `
<tr class="${CLASS_NAMES.fileHeader}">
${iconHeader}
<th id="${IDS.fileColumn}">
${escapeHtml(data.fileColumnLabel)}${sortIcon}${filterButton}
</th>
<th id="${IDS.sizeColumn}">
${escapeHtml(data.sizeColumnLabel)}${sortIcon}
</th>
<th id="${IDS.typeColumn}">
${escapeHtml(data.typeColumnLabel)}${sortIcon}
</th>
${metricsHeader}
${shareHeader}
${statusHeader}
${actionsHeader}
</tr>
`;
},
/**
* @inheritdoc
* @returns {FileTableView} This view
*/
render() {
const data = this.viewModel.toRenderData();
this.closeSubviews();
this.closeEmptyAction();
this.$el
.attr("id", data.tableId || IDS.table)
.attr("class", data.className || this.className)
.html(this.template(data));
setOptionalAttribute(this.$el, "data-id", data.id);
setOptionalAttribute(this.$el, "data-collection-id", data.collectionId);
this.renderRows();
this.applyDisabledState();
return this;
},
/**
* Render the current row collection.
* @returns {FileTableView} This view
*/
renderRows() {
const rows = this.viewModel.getRows();
const body = this.$(`tbody.${CLASS_NAMES.body}`);
if (!body.length) return this;
this.closeSubviews();
this.closeEmptyAction();
body.empty();
if (this.viewModel.get("isLoading")) {
body.html(
this.messageRowTemplate(this.viewModel.get("loadingMessage")),
);
this.applyDisabledState();
return this;
}
if (!rows.length) {
body.html(
this.messageRowTemplate(
this.viewModel.get("emptyMessage"),
Boolean(this.viewModel.getEmptyAction()),
),
);
this.renderEmptyAction();
this.applyDisabledState();
return this;
}
rows.each((rowModel) => {
const rowView = new FileItemView({ viewModel: rowModel });
this.subviews[rowModel.cid] = rowView;
this.listenTo(rowView, "action:click", this.handleActionClick);
this.listenTo(rowView, "rename:commit", this.handleRenameCommit);
body.append(rowView.render().el);
this.listenTo(rowView, "renderComplete", this.handleRowRender);
});
this.applyDisabledState();
return this;
},
/**
* Disable or reenable every interactive element in the rendered table.
* @param {boolean} disabled Whether the table should be disabled
* @returns {FileTableView} This view
*/
setDisabled(disabled) {
const nextDisabled = disabled === true;
if (this.isDisabled === nextDisabled) return this;
this.isDisabled = nextDisabled;
this.applyDisabledState();
return this;
},
/** Apply the current disabled state to rendered controls */
applyDisabledState() {
this.$el
.toggleClass(CLASS_NAMES.tableDisabled, this.isDisabled)
.attr("aria-disabled", this.isDisabled ? "true" : "false");
if (this.isDisabled) {
this.clearDropTarget();
this.closeDropdowns();
this.destroyInitializedTooltips();
this.disableInteractiveElements(this.$el);
return;
}
this.$("[data-ft-disabled-by-table]")
.not("[data-ft-was-disabled='true']")
.prop("disabled", false)
.removeAttr("aria-disabled");
this.$("[data-ft-disabled-by-table]").removeAttr(
"data-ft-disabled-by-table data-ft-was-disabled",
);
this.$("[data-ft-was-contenteditable]")
.attr("contenteditable", "true")
.removeAttr("data-ft-was-contenteditable");
this.$(`.${CLASS_NAMES.dropdownMenu} a`)
.removeClass(CLASS_NAMES.disabled)
.removeAttr("aria-disabled");
},
/**
* Disable interactive elements within a rendered table scope.
* @param {jQuery} scope Rendered table or row element
*/
disableInteractiveElements(scope) {
const controls = scope.find("button, input, textarea, select");
controls
.filter(":disabled")
.not("[data-ft-disabled-by-table]")
.attr("data-ft-was-disabled", "true");
controls
.attr("data-ft-disabled-by-table", "true")
.prop("disabled", true)
.attr("aria-disabled", "true");
scope
.find("[contenteditable='true']")
.attr("data-ft-was-contenteditable", "true")
.attr("contenteditable", "false");
scope
.find(`.${CLASS_NAMES.dropdownMenu} a`)
.addClass(CLASS_NAMES.disabled)
.attr("aria-disabled", "true");
},
/**
* Apply the disabled DOM state to a row that rerendered in place.
* @param {FileItemView} rowView Rendered row view
*/
handleRowRender(rowView) {
if (this.isDisabled) {
this.disableInteractiveElements(rowView.$el);
}
},
/** Close any open Bootstrap dropdowns owned by the table */
closeDropdowns() {
this.$(
`.${CLASS_NAMES.buttonGroup}.${CLASS_NAMES.open}, .${CLASS_NAMES.dropdown}.${CLASS_NAMES.open}`,
).removeClass(CLASS_NAMES.open);
this.$("[data-toggle='dropdown']").attr("aria-expanded", "false");
this.$(".dropdown-backdrop").remove();
},
/** Destroy lazily initialized tooltips owned by the table */
destroyInitializedTooltips() {
this.$("[data-tt-initialized]").each((_index, element) => {
const target = Backbone.$(element);
clearTooltipTimer(target, TOOLTIP_TIMER_KEYS.show);
clearTooltipTimer(target, TOOLTIP_TIMER_KEYS.hide);
});
this.$("[data-tt-initialized]")
.popup("destroy")
.removeAttr("data-tt-initialized");
},
/**
* Inline template for table messages.
* @param {string} message Message to render
* @param {boolean} showAction Whether to render an action placeholder
* @returns {string} Message row markup
*/
messageRowTemplate(message, showAction = false) {
const action = showAction
? `<span class="${CLASS_NAMES.actionPlaceholder}"></span>`
: "";
return `
<tr class="${CLASS_NAMES.messageRow}">
<td colspan="${this.viewModel.getColumnCount()}" class="${CLASS_NAMES.center}">
<span class="${CLASS_NAMES.message}">${escapeHtml(message)}</span>
${action}
</td>
</tr>
`;
},
/** Render the configured empty state action */
renderEmptyAction() {
const actionModel = this.viewModel.getEmptyAction();
if (!actionModel) return;
this.emptyActionView = new FileItemActionView({
viewModel: actionModel,
});
this.listenTo(
this.emptyActionView,
"action:click",
this.handleEmptyActionClick,
);
this.$(`.${CLASS_NAMES.actionPlaceholder}`).append(
this.emptyActionView.render().el,
);
},
/**
* Expand a row's immediate child rows.
* @param {Event} event Click event
*/
expandRow(event) {
event.preventDefault();
if (this.isDisabled) return;
const rowModel = this.getEventRowModel(event);
if (!rowModel) return;
this.viewModel.expandRow(rowModel.get("id"));
this.trigger("row:expand", rowModel, event);
},
/**
* Collapse a row's descendant rows.
* @param {Event} event Click event
*/
collapseRow(event) {
event.preventDefault();
if (this.isDisabled) return;
const rowModel = this.getEventRowModel(event);
if (!rowModel) return;
this.viewModel.collapseRow(rowModel.get("id"));
this.trigger("row:collapse", rowModel, event);
},
/**
* Get the row id associated with a delegated click.
* @param {Event} event Click event
* @returns {string} Row id
*/
getEventRowId(event) {
return this.$(event.currentTarget).closest("tr").attr("data-id") || "";
},
/**
* Get the row model associated with a delegated click.
* @param {Event} event Click event
* @returns {FileItemViewModel|null} Row view model
*/
getEventRowModel(event) {
return this.viewModel.getRows().get(this.getEventRowId(event)) || null;
},
/**
* Bubble row action events.
* @param {FileItemViewModel} rowModel Row view model
* @param {FileItemActionViewModel} actionModel Action view model
* @param {Event} event Click event
*/
handleActionClick(rowModel, actionModel, event) {
if (this.isDisabled) return;
this.closeDropdowns();
this.trigger("action:click", rowModel, actionModel, event);
},
/**
* Bubble table notice actions.
* @param {Event} event Click event
*/
handleNoticeAction(event) {
event.preventDefault();
if (this.isDisabled) return;
const actionId = this.$(event.currentTarget).attr(
"data-notice-action-id",
);
if (actionId) this.trigger("notice:action", actionId, this, event);
},
/**
* Bubble proposed row labels.
* @param {FileItemViewModel} rowModel Row view model
* @param {string} proposedLabel Proposed label
* @param {Event} event Rename event
*/
handleRenameCommit(rowModel, proposedLabel, event) {
if (this.isDisabled) return;
this.trigger("rename:commit", rowModel, proposedLabel, event);
},
/**
* Resolve the destination shared by drop highlighting and file addition.
* Folderless editor tables accept files anywhere; folders need an explicit
* row destination.
* @param {Event} event Drag event
* @returns {{rowModel: FileItemViewModel, element: Element}|null} Drop target
*/
getFileDropTarget(event) {
if (this.isDisabled || this.viewModel.get("isLoading")) return null;
const rows = this.viewModel.getRows();
const root = rows.findWhere({ className: "root-dataset" });
if (root?.get("acceptsFiles") && !rows.findWhere({ kind: "folder" })) {
return { rowModel: root, element: this.el };
}
const rowElement = this.$(event.target).closest("tr[data-id]");
const rowModel = rows.get(rowElement.attr("data-id"));
return rowModel?.get("acceptsFiles")
? { rowModel, element: rowElement[0] }
: null;
},
/**
* Highlight the destination during a file drag.
* @param {Event} event Drag event
*/
showDropTarget(event) {
const transfer = event.originalEvent?.dataTransfer || event.dataTransfer;
if (!transfer?.types.includes("Files")) return;
const target = this.getFileDropTarget(event);
if (target?.element !== this.dropTargetElement) this.clearDropTarget();
if (!target) return;
event.preventDefault();
event.stopPropagation();
this.dropTargetElement = target.element;
target.element.classList.add(CLASS_NAMES.dropTarget);
},
/**
* Keep the highlight while moving between cells of the same destination.
* @param {Event} event Drag event
*/
hideDropTarget(event) {
if (
event.relatedTarget &&
this.dropTargetElement?.contains(event.relatedTarget)
)
return;
this.clearDropTarget();
},
/**
* Clear drag styling when the destination changes or is removed.
*/
clearDropTarget() {
this.dropTargetElement?.classList.remove(CLASS_NAMES.dropTarget);
this.dropTargetElement = null;
},
/**
* Emit files once for the selected table or row destination.
* @param {Event} event Drop event
*/
handleFilesDrop(event) {
const transfer = event.originalEvent?.dataTransfer || event.dataTransfer;
const files = transfer?.files || [];
const target = this.getFileDropTarget(event);
this.clearDropTarget();
if (!files.length) return;
if (!target) {
// A rejected editor drop must not navigate to the dropped file.
if (this.viewModel.getRows().some((row) => row.get("acceptsFiles"))) {
event.preventDefault();
}
return;
}
event.preventDefault();
event.stopPropagation();
this.trigger("files:drop", target.rowModel, files, event);
},
/**
* Stop DOM owned interactions, such as Bootstrap dropdowns, while disabled.
* @param {Event} event Interaction event
*/
blockDisabledInteraction(event) {
if (!this.isDisabled) return;
event.preventDefault();
event.stopImmediatePropagation();
},
/**
* Initialize and show a file table tooltip only after the user asks for it.
* @param {Event} event Hover or focus event
*/
showLazyTooltip(event) {
if (this.isDisabled) return;
const target = this.$(event.currentTarget);
clearTooltipTimer(target, TOOLTIP_TIMER_KEYS.hide);
if (!target.attr("data-tt-initialized")) {
const html = target.attr("data-tt-html");
const content = target.attr("data-tt-content");
if (!html && !content) return;
const tooltip = html ? { html } : { content };
target
.popup({
...TOOLTIP_SETTINGS,
...tooltip,
})
.attr("data-tt-initialized", "true");
}
if (target.data(TOOLTIP_TIMER_KEYS.show)) return;
const showDelay = Number(TOOLTIP_SETTINGS.delay?.show) || 0;
if (showDelay > 0) {
const showTimer = setTimeout(() => {
target.removeData(TOOLTIP_TIMER_KEYS.show);
if (
!this.isDisabled &&
document.documentElement.contains(target[0]) &&
target.attr("data-tt-initialized")
) {
target.popup("show");
}
}, showDelay);
target.data(TOOLTIP_TIMER_KEYS.show, showTimer);
return;
}
target.popup("show");
},
/**
* Hide a lazily initialized tooltip.
* @param {Event} event Hover or focus event
*/
hideLazyTooltip(event) {
const target = this.$(event.currentTarget);
clearTooltipTimer(target, TOOLTIP_TIMER_KEYS.show);
if (!target.attr("data-tt-initialized")) return;
if (target.data(TOOLTIP_TIMER_KEYS.hide)) return;
const hideDelay = Number(TOOLTIP_SETTINGS.delay?.hide) || 0;
if (hideDelay > 0) {
const hideTimer = setTimeout(() => {
target.removeData(TOOLTIP_TIMER_KEYS.hide);
if (
document.documentElement.contains(target[0]) &&
target.attr("data-tt-initialized")
) {
target.popup("hide");
}
}, hideDelay);
target.data(TOOLTIP_TIMER_KEYS.hide, hideTimer);
return;
}
target.popup("hide");
},
/**
* Bubble the empty state action.
* @param {FileItemActionViewModel} actionModel Action view model
* @param {Event} event Click event
*/
handleEmptyActionClick(actionModel, event) {
this.trigger("empty-action:click", actionModel, event);
},
/** Close row subviews */
closeSubviews() {
this.clearDropTarget();
Object.keys(this.subviews).forEach((id) => {
const subview = this.subviews[id];
this.stopListening(subview);
subview.remove();
});
this.subviews = {};
},
/** Close the empty state action view */
closeEmptyAction() {
if (!this.emptyActionView) return;
this.stopListening(this.emptyActionView);
this.emptyActionView.remove();
this.emptyActionView = null;
},
/**
* Close subviews before removing this view.
* @returns {FileTableView} This view
*/
remove() {
this.destroyInitializedTooltips();
this.closeSubviews();
this.closeEmptyAction();
return Backbone.View.prototype.remove.call(this);
},
/** Close this view when the parent table view is closed. */
onClose() {
this.remove();
},
});
return FileTableView;
});