@atlassian/aui
Version:
Atlassian User Interface library
937 lines (804 loc) • 32.9 kB
JavaScript
import $ from './jquery';
import 'jquery-ui/ui/core';
import 'jquery-ui/ui/widget';
import 'jquery-ui/ui/widgets/mouse';
import 'jquery-ui/ui/widgets/draggable';
import 'jquery-ui/ui/widgets/sortable';
import * as logger from './internal/log';
import Backbone from 'backbone';
import classNames from './restful-table/class-names';
import CustomCreateView from './restful-table/custom-create-view';
import CustomEditView from './restful-table/custom-edit-view';
import CustomReadView from './restful-table/custom-read-view';
import dataKeys from './restful-table/data-keys';
import EditRow from './restful-table/edit-row';
import EntryModel from './restful-table/entry-model';
import {triggerEvtForInst} from './restful-table/event-handlers';
import events from './restful-table/event-names';
import globalize from './internal/globalize';
import Row from './restful-table/row';
import { I18n } from './i18n';
import {spinner} from './restful-table/spinner';
/**
* A table whose entries/rows can be retrieved, added and updated via REST (CRUD).
* It uses backbone.js to sync the table's state back to the server, avoiding page refreshes.
*
* @class RestfulTable
*/
var RestfulTable = Backbone.View.extend({
/**
* @param {!Object} options
* ... {!Object} resources
* ... ... {(string|function(function(Array.<Object>)))} all - URL of REST resource OR function that retrieves all entities.
* ... ... {string} self - URL of REST resource to sync a single entities state (CRUD).
* ... {!(selector|Element|jQuery)} el - Table element or selector of the table element to populate.
* ... {!Array.<Object>} columns - Which properties of the entities to render. The id of a column maps to the property of an entity.
* ... {Object} views
* ... ... {RestfulTable.EditRow} editRow - Backbone view that renders the edit & create row. Your view MUST extend RestfulTable.EditRow.
* ... ... {RestfulTable.Row} row - Backbone view that renders the readonly row. Your view MUST extend RestfulTable.Row.
* ... {boolean} allowEdit - Is the table editable. If true, clicking row will switch it to edit state. Default true.
* ... {boolean} allowDelete - Can entries be removed from the table, default true.
* ... {boolean} allowCreate - Can new entries be added to the table, default true.
* ... {boolean} allowReorder - Can we drag rows to reorder them, default false.
* ... {boolean} autoFocus - Automatically set focus to first field on init, default false.
* ... {boolean} reverseOrder - Reverse the order of rows, default false.
* ... {boolean} silent - Do not trigger a "refresh" event on sort, default false.
* ... {String} id - The id for the table. This id will be used to fire events specific to this instance.
* ... {string} createPosition - If set to "bottom", place the create form at the bottom of the table instead of the top.
* ... {string} addPosition - If set to "bottom", add new rows at the bottom of the table instead of the top. If undefined, createPosition will be used to define where to add the new row.
* ... {string} noEntriesMsg - Text to display under the table header if it is empty, default empty.
* ... {string} loadingMsg - Text/HTML to display while loading, default "Loading".
* ... {string} submitAccessKey - Access key for submitting.
* ... {string} cancelAccessKey - Access key for canceling.
* ... @property {RestfulTable~deleteConfirmationCallback} deleteConfirmationCallback - function returning Promise determining if row should be deleted or not
* ... {function(string): (selector|jQuery|Element)} fieldFocusSelector - Element to focus on given a name.
* ... {EntryModel} model - Backbone model representing a row, default EntryModel.
* ... {Backbone.Collection} Collection - Backbone collection representing the entire table, default Backbone.Collection.
* @callback deleteConfirmationCallback
*/
initialize: function (options) {
var instance = this;
// combine default and user options
instance.options = $.extend(true, instance._getDefaultOptions(options), options);
// Prefix events for this instance with this id.
instance.id = this.options.id;
// faster lookup
instance._event = events;
instance.classNames = classNames;
instance.dataKeys = dataKeys;
// shortcuts to popular elements
this.$table = $(options.el)
.addClass(this.classNames.RESTFUL_TABLE)
.addClass(this.classNames.ALLOW_HOVER)
.addClass('aui');
this.$table.wrapAll("<form class='aui' action='#' />");
this.$thead = $('<thead/>');
this.$theadRow = $('<tr />').appendTo(this.$thead);
this.$tbody = $('<tbody/>');
if (!this.$table.length) {
throw new Error('RestfulTable: Init failed! The table you have specified [' + this.$table.selector + '] cannot be found.');
}
if (!this.options.columns) {
throw new Error("RestfulTable: Init failed! You haven't provided any columns to render.");
}
if (this.options.deleteConfirmationCallback && !(this.options.deleteConfirmationCallback instanceof Function)) {
throw new Error('RestfulTable: Init failed! deleteConfirmationCallback is not a function');
}
// Let user know the table is loading
this.showGlobalLoading();
this.options.columns.forEach(function (column) {
var header = $.isFunction(column.header) ? column.header() : column.header;
if (typeof header === 'undefined') {
logger.warn('You have not specified [header] for column [' + column.id + ']. Using id for now...');
header = column.id;
}
instance.$theadRow.append('<th>' + header + '</th>');
});
// columns for submit buttons and loading indicator used when editing
instance.$theadRow.append('<th></th><th></th>');
// create a new Backbone collection to represent rows (http://documentcloud.github.com/backbone/#Collection)
this._models = this._createCollection();
// shortcut to the class we use to create rows
this._rowClass = this.options.views.row;
this.editRows = []; // keep track of rows that are being edited concurrently
this.$table.closest('form').submit(function (e) {
if (instance.focusedRow) {
// Delegates saving of row. See EditRow.submit
instance.focusedRow.trigger(instance._event.SAVE);
}
e.preventDefault();
});
if (this.options.allowReorder) {
// Add allowance for another cell to the <thead>
this.$theadRow.prepend('<th />');
// Allow drag and drop reordering of rows
this.$tbody.sortable({
handle: '.' + this.classNames.DRAG_HANDLE,
helper: function (e, elt) {
var helper = $('<div/>').attr('class', elt.attr('class')).addClass(instance.classNames.MOVEABLE);
elt.children().each(function () {
var $td = $(this);
// .offsetWidth/.outerWidth() is broken in webkit for tables, so we do .clientWidth + borders
// Need to coerce the border-left-width to an in because IE - http://bugs.jquery.com/ticket/10855
var borderLeft = parseInt(0 + $td.css('border-left-width'), 10);
var borderRight = parseInt(0 + $td.css('border-right-width'), 10);
var width = $td[0].clientWidth + borderLeft + borderRight;
helper.append($('<div/>').html($td.html()).attr('class', $td.attr('class')).width(width));
});
helper = $("<div class='aui-restfultable-readonly'/>").append(helper); // Basically just to get the styles.
helper.css({left: elt.offset().left}); // To align with the other table rows, since we've locked scrolling on x.
helper.appendTo(document.body);
return helper;
},
start: function (event, ui) {
var cachedHeight = ui.helper[0].clientHeight;
var $this = ui.placeholder.find('td');
// Make sure that when we start dragging widths do not change
ui.item
.addClass(instance.classNames.MOVEABLE)
.children().each(function (i) {
$(this).width($this.eq(i).width());
});
// Create a <td> to add to the placeholder <tr> to inherit CSS styles.
var td = '<td colspan="' + instance.getColumnCount() + '"> </td>';
ui.placeholder.html(td).css({
height: cachedHeight,
visibility: 'visible'
});
// Stop hover effects etc from occuring as we move the mouse (while dragging) over other rows
instance.getRowFromElement(ui.item[0]).trigger(instance._event.MODAL);
},
stop: function (event, ui) {
if ($(ui.item[0]).is(':visible')) {
ui.item
.removeClass(instance.classNames.MOVEABLE)
.children().attr('style', '');
ui.placeholder.removeClass(instance.classNames.ROW);
// Return table to a normal state
instance.getRowFromElement(ui.item[0]).trigger(instance._event.MODELESS);
}
},
update: function (event, ui) {
var context = {
row: instance.getRowFromElement(ui.item[0]),
item: ui.item,
nextItem: ui.item.next(),
prevItem: ui.item.prev()
};
instance.move(context);
},
axis: 'y',
delay: 0,
containment: 'document',
cursor: 'move',
scroll: true,
zIndex: 8000
});
// Prevent text selection while reordering.
this.$tbody.on('selectstart mousedown', function (event) {
return !$(event.target).is('.' + instance.classNames.DRAG_HANDLE);
});
}
if (this.options.allowCreate !== false) {
// Create row responsible for adding new entries ...
this._createRow = new this.options.views.editRow({
columns: this.options.columns,
isCreateRow: true,
model: this.options.model.extend({
url: function () {
return instance.options.resources.self;
}
}),
cancelAccessKey: this.options.cancelAccessKey,
submitAccessKey: this.options.submitAccessKey,
allowReorder: this.options.allowReorder,
fieldFocusSelector: this.options.fieldFocusSelector
});
this._createRow.on(this._event.CREATED, function (values) {
if ((typeof instance.options.addPosition === 'undefined' && instance.options.createPosition === 'bottom') ||
instance.options.addPosition === 'bottom') {
instance.addRow(values);
} else {
instance.addRow(values, 0);
}
});
this._createRow.on(this._event.VALIDATION_ERROR, function () {
this.trigger(instance._event.FOCUS);
});
this._createRow.render({
errors: {},
values: {}
});
// ... and appends it as the first row
this.$create = $('<tbody class="' + this.classNames.CREATE + '" />')
.append(this._createRow.el);
// Manage which row has focus
this._applyFocusCoordinator(this._createRow);
// focus create row
if (this.options.autoFocus) {
this._createRow.trigger(this._event.FOCUS);
}
}
// when a model is removed from the collection, remove it from the viewport also
this._models.on('remove', function (model) {
instance.getRows().forEach(function (row) {
if (row.model === model) {
if (row.hasFocus() && instance._createRow) {
instance._createRow.trigger(instance._event.FOCUS);
}
instance.removeRow(row);
}
});
});
this.fetchInitialResources();
},
fetchInitialResources: function () {
var instance = this;
if ($.isFunction(this.options.resources.all)) {
this.options.resources.all(function (entries) {
instance.populate(entries);
});
} else {
$.get(this.options.resources.all, function (entries) {
instance.populate(entries);
});
}
},
move: function (context) {
var instance = this;
var createRequest = function (afterElement) {
if (!afterElement.length) {
return {
position: 'First'
};
} else {
var afterModel = instance.getRowFromElement(afterElement).model;
return {
after: afterModel.url()
};
}
};
if (context.row) {
var data = instance.options.reverseOrder ? createRequest(context.nextItem) : createRequest(context.prevItem);
$.ajax({
url: context.row.model.url() + '/move',
type: 'POST',
dataType: 'json',
contentType: 'application/json',
data: JSON.stringify(data),
complete: function () {
// hides loading indicator (spinner)
context.row.hideLoading();
},
success: function (xhr) {
triggerEvtForInst(instance._event.REORDER_SUCCESS, instance, [xhr]);
},
error: function (xhr) {
var responseData = $.parseJSON(xhr.responseText || xhr.data);
triggerEvtForInst(instance._event.SERVER_ERROR, instance, [responseData, xhr, this]);
}
});
// shows loading indicator (spinner)
context.row.showLoading();
}
},
_createCollection: function () {
var instance = this;
// create a new Backbone collection to represent rows (http://documentcloud.github.com/backbone/#Collection)
var RowsAwareCollection = this.options.Collection.extend({
// Force the collection to re-sort itself. You don't need to call this under normal
// circumstances, as the set will maintain sort order as each item is added.
sort: function (options) {
options || (options = {});
if (!this.comparator) {
throw new Error('Cannot sort a set without a comparator');
}
this.tableRows = instance.getRows();
this.models = this.sortBy(this.comparator, this);
this.tableRows = undefined;
if (!options.silent) {
this.trigger('refresh', this, options);
}
return this;
},
remove: function (...args) {
this.tableRows = instance.getRows();
Backbone.Collection.prototype.remove.apply(this, args);
this.tableRows = undefined;
return this;
}
});
return new RowsAwareCollection([], {
comparator: function (row) {
// sort models in collection based on dom ordering
var index;
var currentTableRows = (this && this.tableRows !== undefined) ? this.tableRows : instance.getRows();
currentTableRows.some(function (item, i) {
if (item.model.id === row.id) {
index = i;
return true;
}
});
return index;
}
});
},
/**
* Refreshes table with entries
*
* @param entries
*/
populate: function (entries) {
if (this.options.reverseOrder) {
entries.reverse();
}
this.hideGlobalLoading();
if (entries && entries.length) {
// Empty the models collection
this._models.reset([], {silent: true});
// Add all the entries to collection and render them
this.renderRows(entries);
// show message to user if we have no entries
if (this.isEmpty()) {
this.showNoEntriesMsg();
}
} else {
this.showNoEntriesMsg();
}
// Ok, lets let everyone know that we are done...
this.$table
.append(this.$thead);
if (this.options.createPosition === 'bottom') {
this.$table.append(this.$tbody)
.append(this.$create);
} else {
this.$table
.append(this.$create)
.append(this.$tbody);
}
this.$table.trigger(this._event.INITIALIZED, [this]);
triggerEvtForInst(this._event.INITIALIZED, this, [this]);
if (this.options.autoFocus) {
this.$table.find(':input:text:first').focus(); // set focus to first field
}
},
/**
* Shows loading indicator and text
*
* @return {RestfulTable}
*/
showGlobalLoading: function () {
if (!this.$loading) {
this.$loading = $('<div class="aui-restfultable-init">' +
'<span class="aui-restfultable-loading">' + spinner + this.options.loadingMsg + '</span></div>');
}
if (!this.$loading.is(':visible')) {
this.$loading.insertAfter(this.$table);
}
return this;
},
/**
* Hides loading indicator and text
* @return {RestfulTable}
*/
hideGlobalLoading: function () {
if (this.$loading) {
this.$loading.remove();
}
return this;
},
/**
* Adds row to collection and renders it
*
* @param {Object} values
* @param {number} index
* @return {RestfulTable}
*/
addRow: function (values, index) {
var view;
var model;
if (!values.id) {
throw new Error('RestfulTable.addRow: to add a row values object must contain an id. ' +
'Maybe you are not returning it from your restend point?' +
'Recieved:' + JSON.stringify(values));
}
model = new this.options.model(values);
view = this._renderRow(model, index);
this._models.add(model);
this.removeNoEntriesMsg();
// Let everyone know we added a row
triggerEvtForInst(this._event.ROW_ADDED, this, [view, this]);
return this;
},
/**
* Provided a view, removes it from display and backbone collection
*
* @param row {Row} The row to remove.
*/
removeRow: function (row) {
this._models.remove(row.model);
row.remove();
if (this.isEmpty()) {
this.showNoEntriesMsg();
}
// Let everyone know we removed a row
triggerEvtForInst(this._event.ROW_REMOVED, this, [row, this]);
},
/**
* Is there any entries in the table
*
* @return {Boolean}
*/
isEmpty: function () {
return this._models.length === 0;
},
/**
* Gets all models
*
* @return {Backbone.Collection}
*/
getModels: function () {
return this._models;
},
/**
* Gets table body
*
* @return {jQuery}
*/
getTable: function () {
return this.$table;
},
/**
* Gets table body
*
* @return {jQuery}
*/
getTableBody: function () {
return this.$tbody;
},
/**
* Gets create Row
*
* @return {EditRow}
*/
getCreateRow: function () {
return this._createRow;
},
/**
* Gets the number of table columns, accounting for the number of
* additional columns added by RestfulTable itself
* (such as the drag handle column, buttons and actions columns)
*
* @return {Number}
*/
getColumnCount: function () {
var staticFieldCount = 2; // accounts for the columns allocated to submit buttons and loading indicator
if (this.allowReorder) {
++staticFieldCount;
}
return this.options.columns.length + staticFieldCount;
},
/**
* Get the Row that corresponds to the given <tr> element.
*
* @param {HTMLElement} tr
*
* @return {Row}
*/
getRowFromElement: function (tr) {
return $(tr).data(this.dataKeys.ROW_VIEW);
},
/**
* Shows message {options.noEntriesMsg} to the user if there are no entries
*
* @return {RestfulTable}
*/
showNoEntriesMsg: function () {
if (this.$noEntries) {
this.$noEntries.remove();
}
this.$noEntries = $('<tr>')
.addClass(this.classNames.NO_ENTRIES)
.append($('<td>')
.attr('colspan', this.getColumnCount())
.text(this.options.noEntriesMsg)
)
.appendTo(this.$tbody);
return this;
},
/**
* Removes message {options.noEntriesMsg} to the user if there ARE entries
*
* @return {RestfulTable}
*/
removeNoEntriesMsg: function () {
if (this.$noEntries && this._models.length > 0) {
this.$noEntries.remove();
}
return this;
},
/**
* Gets the Row from their associated <tr> elements
*
* @return {Array}
*/
getRows: function () {
var instance = this;
var views = [];
this.$tbody.find('.' + this.classNames.READ_ONLY).each(function () {
var $row = $(this);
var view = $row.data(instance.dataKeys.ROW_VIEW);
if (view) {
views.push(view);
}
});
return views;
},
/**
* Appends entry to end or specified index of table
*
* @param {EntryModel} model
* @param index
*
* @return {jQuery}
*/
_renderRow: function (model, index) {
var instance = this;
var $rows = this.$tbody.find('.' + this.classNames.READ_ONLY);
var $row;
var view;
view = new this._rowClass({
model: model,
columns: this.options.columns,
allowEdit: this.options.allowEdit,
allowDelete: this.options.allowDelete,
allowReorder: this.options.allowReorder,
deleteConfirmationCallback: this.options.deleteConfirmationCallback
});
this.removeNoEntriesMsg();
view.on(this._event.ROW_EDIT, function (field) {
triggerEvtForInst(this._event.EDIT_ROW, {}, [this, instance]);
instance.edit(this, field);
});
$row = view.render().$el;
if (index !== -1) {
if (typeof index === 'number' && $rows.length !== 0) {
$row.insertBefore($rows[index]);
} else {
this.$tbody.append($row);
}
}
$row.data(this.dataKeys.ROW_VIEW, view);
// deactivate all rows - used in the cases, such as opening a dropdown where you do not want the table editable
// or any interactions
view.on(this._event.MODAL, function () {
instance.$table.removeClass(instance.classNames.ALLOW_HOVER);
instance.$tbody.sortable('disable');
instance.getRows().forEach(function (row) {
if (!instance.isRowBeingEdited(row)) {
row.delegateEvents({}); // clear all events
}
});
});
// activate all rows - used in the cases, such as opening a dropdown where you do not want the table editable
// or any interactions
view.on(this._event.MODELESS, function () {
instance.$table.addClass(instance.classNames.ALLOW_HOVER);
instance.$tbody.sortable('enable');
instance.getRows().forEach(function (row) {
if (!instance.isRowBeingEdited(row)) {
row.delegateEvents(); // rebind all events
}
});
});
// ensure that when this row is focused no other are
this._applyFocusCoordinator(view);
this.trigger(this._event.ROW_INITIALIZED, view);
return view;
},
/**
* Returns if the row is edit mode or note.
*
* @param {Row} row Read-only row to check if being edited.
*
* @return {Boolean}
*/
isRowBeingEdited: function (row) {
var isBeingEdited = false;
this.editRows.some(function (editRow) {
if (editRow.el === row.el) {
isBeingEdited = true;
return true;
}
});
return isBeingEdited;
},
/**
* Ensures that when supplied view is focused no others are
*
* @param {Backbone.View} view
* @return {RestfulTable}
*/
_applyFocusCoordinator: function (view) {
var instance = this;
if (!view.hasFocusBound) {
view.hasFocusBound = true;
view.on(this._event.FOCUS, function () {
if (instance.focusedRow && instance.focusedRow !== view) {
instance.focusedRow.trigger(instance._event.BLUR);
}
instance.focusedRow = view;
if (view instanceof Row && instance._createRow) {
instance._createRow.enable();
}
});
}
return this;
},
/**
* Remove specified row from collection holding rows being concurrently edited
*
* @param {EditRow} editView
*
* @return {RestfulTable}
*/
_removeEditRow: function (editView) {
var index = $.inArray(editView, this.editRows);
this.editRows.splice(index, 1);
return this;
},
/**
* Focuses last row still being edited or create row (if it exists)
*
* @return {RestfulTable}
*/
_shiftFocusAfterEdit: function () {
if (this.editRows.length > 0) {
this.editRows[this.editRows.length - 1].trigger(this._event.FOCUS);
} else if (this._createRow) {
this._createRow.trigger(this._event.FOCUS);
}
return this;
},
/**
* Evaluate if we save row when we blur. We can only do this when there is one row being edited at a time, otherwise
* it causes an infinite loop JRADEV-5325
*
* @return {boolean}
*/
_saveEditRowOnBlur: function () {
return this.editRows.length <= 1;
},
/**
* Dismisses rows being edited concurrently that have no changes
*/
dismissEditRows: function () {
this.editRows.forEach(function (editRow) {
if (!editRow.hasUpdates()) {
editRow.trigger(this._event.FINISHED_EDITING);
}
}, this);
},
/**
* Converts readonly row to editable view
*
* @param {Backbone.View} row
* @param {String} field - field name to focus
* @return {Backbone.View} editRow
*/
edit: function (row, field) {
var instance = this;
var editRow = new this.options.views.editRow({
el: row.el,
columns: this.options.columns,
isUpdateMode: true,
allowReorder: this.options.allowReorder,
fieldFocusSelector: this.options.fieldFocusSelector,
model: row.model,
cancelAccessKey: this.options.cancelAccessKey,
submitAccessKey: this.options.submitAccessKey
});
var values = row.model.toJSON();
values.update = true;
editRow.render({
errors: {},
update: true,
values: values
})
.on(instance._event.UPDATED, function (model, focusUpdated) {
instance._removeEditRow (this);
this.off();
row.render().delegateEvents(); // render and rebind events
row.trigger(instance._event.UPDATED); // trigger blur fade out
if (focusUpdated !== false) {
instance._shiftFocusAfterEdit();
}
})
.on(instance._event.VALIDATION_ERROR, function () {
this.trigger(instance._event.FOCUS);
})
.on(instance._event.FINISHED_EDITING, function () {
instance._removeEditRow(this);
row.render().delegateEvents();
this.off(); // avoid any other updating, blurring, finished editing, cancel events being fired
})
.on(instance._event.CANCEL, function () {
instance._removeEditRow(this);
this.off(); // avoid any other updating, blurring, finished editing, cancel events being fired
row.render().delegateEvents(); // render and re` events
instance._shiftFocusAfterEdit();
})
.on(instance._event.BLUR, function () {
instance.dismissEditRows(); // dismiss edit rows that have no changes
if (instance._saveEditRowOnBlur()) {
this.trigger(instance._event.SAVE, false); // save row, which if successful will call the updated event above
}
});
// Ensure that if focus is pulled to another row, we blur the edit row
this._applyFocusCoordinator(editRow);
// focus edit row, which has the flow on effect of blurring current focused row
editRow.trigger(instance._event.FOCUS, field);
// disables form fields
if (instance._createRow) {
instance._createRow.disable();
}
this.editRows.push(editRow);
return editRow;
},
/**
* Renders all specified rows
*
* @param rows {Array<Backbone.Model>} array of objects describing Backbone.Model's to render
* @return {RestfulTable}
*/
renderRows: function (rows = []) {
var comparator = this._models.comparator;
var els = [];
this._models.comparator = undefined; // disable temporarily, assume rows are sorted
var models = rows.map((row) => {
var model = new this.options.model(row);
els.push(this._renderRow(model, -1).el);
return model;
});
this._models.add(models, {silent: true});
this._models.comparator = comparator;
this.removeNoEntriesMsg();
this.$tbody.append(els);
return this;
},
/**
* Gets default options
*
* @param {Object} options
*/
_getDefaultOptions: function (options) {
return {
model: options.model || EntryModel,
allowEdit: true,
views: {
editRow: EditRow,
row: Row
},
Collection: Backbone.Collection.extend({
url: options.resources.self,
model: options.model || EntryModel
}),
allowReorder: false,
fieldFocusSelector: function (name) {
return ':input[name=' + name + '], #' + name;
},
loadingMsg: options.loadingMsg || I18n.getText('aui.words.loading')
};
}
});
RestfulTable.ClassNames = classNames;
RestfulTable.CustomCreateView = CustomCreateView;
RestfulTable.CustomEditView = CustomEditView;
RestfulTable.CustomReadView = CustomReadView;
RestfulTable.DataKeys = dataKeys;
RestfulTable.EditRow = EditRow;
RestfulTable.EntryModel = EntryModel;
RestfulTable.Events = events;
RestfulTable.Row = Row;
globalize('RestfulTable', RestfulTable);
export default RestfulTable;