"use strict";
define([
"backbone",
"underscore",
"models/fileTable/FileItemActionViewModel",
"models/fileTable/FileItemViewModel",
], (Backbone, _, FileItemActionViewModel, FileItemViewModel) => {
/**
* @class FileItemCollection
* @classdesc Collection of file table row view models
* @classcategory Models/FileTable
* @since 2.39.0
* @augments Backbone.Collection
*/
const FileItemCollection = Backbone.Collection.extend({
model: FileItemViewModel,
});
/**
* Convert row input to a collection of row view models.
* @param {Backbone.Collection|object[]} rows Row data
* @returns {Backbone.Collection} Row view model collection
*/
function normalizeRows(rows) {
if (rows instanceof FileItemCollection) return rows;
if (rows instanceof Backbone.Collection) {
return new FileItemCollection(rows.toJSON());
}
return new FileItemCollection(rows || []);
}
/**
* @class FileTableViewModel
* @classdesc Generic render state and hierarchy state for a file table
* This model owns display only table state; it does not fetch, upload,
* download, or search for files
* @classcategory Models/FileTable
* @since 2.39.0
* @augments Backbone.Model
*/
const FileTableViewModel = Backbone.Model.extend({
/** @returns {object} Default file table state */
defaults() {
return {
id: "",
collectionId: "",
title: "Files",
subtitle: "",
emptyMessage: "No files to display.",
loadingMessage: "Loading files...",
noticeMessage: "",
noticeClassName: "file-listing-note",
noticeIconClass: "icon icon-info-sign icon-on-left",
noticeActionId: "",
noticeActionLabel: "",
noticeActionClassName: "btn",
fileColumnLabel: "Files",
sizeColumnLabel: "Size",
typeColumnLabel: "Type",
metricsColumnLabel: "Metrics",
shareColumnLabel: "Share",
statusColumnLabel: "Status",
actionsColumnLabel: "Actions",
className:
"table table-striped table-hover download-contents table-condensed",
bodyId: "",
showIconColumn: false,
showTitle: true,
showHeader: true,
showMetrics: false,
showShare: false,
showStatus: false,
showActions: true,
showFilteringControl: false,
showSortingControl: false,
isLoading: false,
emptyAction: null,
rows: new FileItemCollection(),
};
},
/**
* Normalize nested view model attributes.
* @inheritdoc
*/
set(key, value, options) {
let attributes = key;
let setOptions = options;
if (key == null) return this;
if (typeof key === "string") {
attributes = { [key]: value };
} else {
setOptions = value;
attributes = { ...key };
}
if (
Object.prototype.hasOwnProperty.call(attributes, "emptyAction") &&
attributes.emptyAction &&
!(attributes.emptyAction instanceof FileItemActionViewModel)
) {
attributes.emptyAction = new FileItemActionViewModel(
attributes.emptyAction,
);
}
return Backbone.Model.prototype.set.call(this, attributes, setOptions);
},
/** @inheritdoc */
initialize(attributes = {}) {
const rows = Object.prototype.hasOwnProperty.call(attributes, "rows")
? attributes.rows
: this.get("rows");
this.set(
{
bodyId: this.get("bodyId") || "data-package-table-body",
rows: normalizeRows(rows),
},
{ silent: true },
);
this.listenTo(
this,
"change:showMetrics change:showShare change:showStatus change:showActions",
this.syncColumnState,
);
this.listenToRows();
},
/** @returns {Backbone.Collection} Row view models */
getRows() {
return this.get("rows");
},
/**
* Replace all rows with new row state.
* @param {Backbone.Collection|object[]} rows Row state
* @param {object} [options] Backbone set/reset options
* @returns {FileTableViewModel} This model
*/
setRows(rows = [], options = {}) {
const oldRows = this.getRows();
const nextRows = normalizeRows(rows);
if (oldRows) this.stopListening(oldRows);
this.set("rows", nextRows, options);
this.listenToRows();
this.trigger("rows:update", this, nextRows, options);
return this;
},
/**
* Merge row data into the existing row collection without replacing the row
* models that survive. Existing rows are updated in place, rows absent from
* the update are removed, new rows are added, and the incoming order is
* reimposed. Derived UI state (`hasChildren`, `isExpanded`, `isVisible`) is
* preserved on surviving rows, so a refresh does not collapse open folders.
* Surviving actions keep their models and pending feedback while accepting
* updated labels and availability.
*
* An attribute only merge (same rows, same order) mutates only the changed
* rows and does not trigger a `rows:update`, so the table is not rebuilt.
* @param {Backbone.Collection|object[]} rows Row state
* @param {object} [options] Backbone set options
* @returns {FileTableViewModel} This model
*/
mergeRows(rows = [], options = {}) {
const currentRows = this.getRows();
const nextRows = normalizeRows(rows);
const preservedKeys = ["hasChildren", "isExpanded", "isVisible"];
const comparable = (value) => (value?.toJSON ? value.toJSON() : value);
const mergedRows = [];
nextRows.each((nextRow) => {
const currentRow = currentRows.get(nextRow.id);
if (!currentRow) {
mergedRows.push(nextRow.toJSON());
return;
}
const changes = {};
Object.entries(nextRow.toJSON()).forEach(([key, value]) => {
if (preservedKeys.includes(key)) return;
if (key === "actions") {
const actions = currentRow.getActions();
const nextActions = value.map((action) => ({
...action.toJSON(),
pending: actions.get(action.id)?.get("pending") || null,
}));
if (!_.isEqual(actions.toJSON(), nextActions)) {
actions.set(nextActions, options);
}
return;
}
if (!_.isEqual(comparable(currentRow.get(key)), comparable(value))) {
changes[key] = value;
}
});
if (Object.keys(changes).length) currentRow.set(changes, options);
this.applyTableStateToRow(currentRow);
mergedRows.push(currentRow);
});
const hasStructuralChanges =
currentRows.length !== mergedRows.length ||
mergedRows.some((row) => !currentRows.get(row.id));
const hasOrderChanges = mergedRows.some(
(row, index) => row !== currentRows.at(index),
);
currentRows.set(mergedRows, { ...options, merge: false });
if (!hasStructuralChanges && hasOrderChanges && !options.silent) {
this.trigger("rows:update", this, currentRows);
}
return this;
},
/**
* Reimpose an explicit row order (by id) on the row collection. Rows are
* reordered in place and a single `sort` event is emitted only when the
* order actually changes. Rows whose id is not listed (defensive) keep their
* relative order at the end.
* @param {string[]} orderedIds Row ids in the desired display order
* @param {object} [options] Backbone event options
* @returns {FileTableViewModel} This model
*/
orderRows(orderedIds = [], options = {}) {
const rows = this.getRows();
if (rows.length < 2) return this;
const rank = new Map(orderedIds.map((id, index) => [id, index]));
const rankOf = (row) =>
rank.has(row.id) ? rank.get(row.id) : Number.MAX_SAFE_INTEGER;
const current = rows.models;
const sorted = [...current].sort((a, b) => rankOf(a) - rankOf(b));
if (sorted.every((row, index) => row === current[index])) return this;
rows.models = sorted;
if (!options.silent) {
rows.trigger("sort", rows, options);
this.trigger("rows:update", this, rows);
}
return this;
},
/**
* Generic table update entry point.
* @param {object} attributes Attributes to set on the view model
* @param {object} [options] Backbone set/reset options
* @returns {FileTableViewModel} This model
*/
update(attributes = {}, options = {}) {
const nextAttributes = { ...attributes };
const hasRows = Object.prototype.hasOwnProperty.call(
nextAttributes,
"rows",
);
const { rows } = nextAttributes;
if (hasRows) delete nextAttributes.rows;
if (Object.keys(nextAttributes).length) this.set(nextAttributes, options);
if (hasRows) this.setRows(rows, options);
return this;
},
/**
* Add one row.
* @param {object|FileItemViewModel} row Row state
* @param {object} [options] Backbone add options
* @returns {FileItemViewModel} Added row
*/
addRow(row, options = {}) {
return this.getRows().add(row, options);
},
/**
* Update one row by id.
* @param {string} id Row id
* @param {object} attributes Attributes to set
* @param {object} [options] Backbone set options
* @returns {FileItemViewModel|null} Updated row, if found
*/
updateRow(id, attributes = {}, options = {}) {
const row = this.getRows().get(id);
if (!row) return null;
row.update(attributes, options);
return row;
},
/**
* Remove one row by id.
* @param {string} id Row id
* @param {object} [options] Backbone remove options
* @returns {FileItemViewModel|null} Removed row, if found
*/
removeRow(id, options = {}) {
const rows = this.getRows();
const row = rows.get(id);
if (!row) return null;
rows.remove(row, options);
return row;
},
/** Wire table level hierarchy state to row changes */
listenToRows() {
const rows = this.getRows();
if (!rows) return;
this.listenTo(rows, "update reset sort", this.refreshHierarchyState);
this.listenTo(
rows,
"change:id change:parentId",
this.refreshHierarchyState,
);
this.listenTo(rows, "change:isExpanded", (row, value, options = {}) => {
if (!options.skipHierarchyRefresh) this.refreshHierarchyVisibility();
});
this.listenTo(rows, "update reset", () => {
this.trigger("rows:update", this, rows);
});
this.listenTo(rows, "add", (row) => this.applyTableStateToRow(row));
rows.each((row) => this.applyTableStateToRow(row));
this.refreshHierarchyState();
},
/**
* Apply table level display state to a row.
* @param {FileItemViewModel} row Row view model
* @param {object} [options] Backbone set options
*/
applyTableStateToRow(row, options = {}) {
const attrs = {};
if (!row.has("collectionId") || !row.get("collectionId")) {
attrs.collectionId = this.get("collectionId") || this.get("id");
}
attrs.showMetrics = this.get("showMetrics");
attrs.showIconColumn = this.get("showIconColumn");
attrs.showShare = this.get("showShare");
attrs.showStatus = this.get("showStatus");
attrs.showActions = this.get("showActions");
row.set(attrs, options);
},
/** Keep every row aligned with the table's optional column state */
syncColumnState() {
this.getRows().each((row) => {
this.applyTableStateToRow(row, { silent: true });
});
},
/** Mark rows that have children so row views can render expand controls */
refreshHierarchyState() {
const rows = this.getRows();
const childrenByParent = new Map();
rows.each((row) => {
const parentId = row.get("parentId");
if (!parentId) return;
if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, []);
childrenByParent.get(parentId).push(row);
});
rows.each((row) => {
row.set("hasChildren", childrenByParent.has(row.get("id")));
});
this.refreshHierarchyVisibility(childrenByParent);
},
/**
* Derive row visibility from ancestor expansion state.
* @param {Map<string, FileItemViewModel[]>} [childrenByParent] Rows keyed
* by parent id
*/
refreshHierarchyVisibility(childrenByParent) {
const rows = this.getRows();
const childRowsByParent = childrenByParent || new Map();
const visibilityByRow = new Map();
if (!childrenByParent) {
rows.each((row) => {
const parentId = row.get("parentId");
if (!parentId) return;
if (!childRowsByParent.has(parentId)) {
childRowsByParent.set(parentId, []);
}
childRowsByParent.get(parentId).push(row);
});
}
const setVisibility = (row, isVisible) => {
visibilityByRow.set(row, isVisible);
const childrenVisible = isVisible && row.get("isExpanded");
(childRowsByParent.get(row.get("id")) || []).forEach((child) => {
setVisibility(child, childrenVisible);
});
};
rows
.filter((row) => !row.get("parentId"))
.forEach((row) => setVisibility(row, true));
rows.each((row) => {
row.set("isVisible", visibilityByRow.get(row) === true);
});
},
/**
* Set expanded state for one row and update descendant visibility.
* @param {string} id Row id
* @param {boolean} isExpanded Whether the row is expanded
* @returns {FileItemViewModel|null} Updated row, if found
*/
setRowExpanded(id, isExpanded) {
const row = this.getRows().get(id);
if (!row) return null;
row.set("isExpanded", Boolean(isExpanded), {
skipHierarchyRefresh: true,
});
this.refreshHierarchyVisibility();
return row;
},
/**
* Expand one row.
* @param {string} id Row id
* @returns {FileItemViewModel|null} Updated row, if found
*/
expandRow(id) {
return this.setRowExpanded(id, true);
},
/**
* Collapse one row.
* @param {string} id Row id
* @returns {FileItemViewModel|null} Updated row, if found
*/
collapseRow(id) {
return this.setRowExpanded(id, false);
},
/**
* Expand all rows.
* @returns {FileTableViewModel} This model
*/
expandAll() {
this.getRows().each((row) => {
row.set("isExpanded", true, { skipHierarchyRefresh: true });
});
this.refreshHierarchyVisibility();
return this;
},
/**
* Collapse all container rows while keeping top level rows visible.
* @returns {FileTableViewModel} This model
*/
collapseAll() {
this.getRows().each((row) => {
row.set("isExpanded", false, { skipHierarchyRefresh: true });
});
this.refreshHierarchyVisibility();
return this;
},
/**
* Column count for message rows.
* @returns {number} Number of rendered columns
*/
getColumnCount() {
return (
3 +
(this.get("showIconColumn") ? 1 : 0) +
(this.get("showMetrics") ? 1 : 0) +
(this.get("showShare") ? 1 : 0) +
(this.get("showStatus") ? 1 : 0) +
(this.get("showActions") ? 1 : 0)
);
},
/** @returns {FileItemActionViewModel|null} Empty state action */
getEmptyAction() {
return this.get("emptyAction");
},
/**
* Attributes consumed directly by the view.
* @returns {object} Render ready attributes
*/
toRenderData() {
return {
...this.toJSON(),
columnCount: this.getColumnCount(),
};
},
});
return FileTableViewModel;
});