UNPKG

@atlassian/aui

Version:

Atlassian User Interface library

502 lines (428 loc) 14.9 kB
import $ from '../jquery'; import '../../../js-vendor/jquery/serializetoobject'; import Backbone from 'backbone'; import classNames from './class-names'; import dataKeys from './data-keys'; import events from './event-names'; import { I18n } from '../i18n'; import { appendStatusSpinner, removeStatusSpinner } from './spinner'; var isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') !== -1; /** * An abstract class that gives the required behaviour for the creating and editing entries. Extend this class and pass * it as the {views.row} property of the options passed to RestfulTable in construction. */ export default Backbone.View.extend({ tagName: 'tr', // delegate events events: { focusin: '_focus', click: '_focus', keyup: '_handleKeyUpEvent', }, /** * @constructor * @param {Object} options */ initialize: function (options) { this.$el = $(this.el); // faster lookup this._event = events; this.classNames = classNames; this.dataKeys = dataKeys; this.columns = options.columns; this.isCreateRow = options.isCreateRow; this.allowReorder = options.allowReorder; // Allow cancelling an edit with support for setting a new element. this.events['click .' + this.classNames.CANCEL] = '_cancel'; this.delegateEvents(); if (options.isUpdateMode) { this.isUpdateMode = true; } else { this._modelClass = options.model; this.model = new this._modelClass(); } this.fieldFocusSelector = options.fieldFocusSelector; this.on(this._event.CANCEL, () => { if (!this.isCreateRow) { this.disabled = true; } }) .on(this._event.SAVE, (focusUpdated) => !this.disabled && this.submit(focusUpdated)) .on(this._event.FOCUS, (name) => this.focus(name)) .on(this._event.BLUR, () => { this.$el.removeClass(this.classNames.FOCUSED); this.disable(); }) .on(this._event.SUBMIT_STARTED, () => this._submitStarted()) .on(this._event.SUBMIT_FINISHED, () => this._submitFinished()); }, /** * Renders default cell contents * * @param data */ defaultColumnRenderer: function (data) { if (data.allowEdit !== false) { return $("<input type='text' />").addClass('text').attr({ 'name': data.name, 'value': data.value, 'aria-label': data.ariaLabel, }); } else if (data.value) { return document.createTextNode(data.value); } }, /** * Renders drag handle * @return jQuery */ renderDragHandle: function () { return '<span class="' + this.classNames.DRAG_HANDLE + '"></span></td>'; }, /** * Executes cancel event if ESC is pressed * * @param {Event} e */ _handleKeyUpEvent: function (e) { if (e.keyCode === 27) { this.trigger(this._event.CANCEL); } }, /** * Fires cancel event * * @param {Event} e * * @return EditRow */ _cancel: function (e) { this.trigger(this._event.CANCEL); e.preventDefault(); return this; }, /** * Disables events/fields and adds safe guard against double submitting * * @return EditRow */ _submitStarted: function () { this.submitting = true; this.showLoading().disable().delegateEvents({}); return this; }, /** * Enables events & fields * * @return EditRow */ _submitFinished: function () { this.submitting = false; this.hideLoading().enable().delegateEvents(this.events); return this; }, /** * Handles dom focus event, by only focusing row if it isn't already * * @param {Event} e * * @return EditRow */ _focus: function (e) { if (!this.hasFocus()) { this.trigger(this._event.FOCUS, e.target.name); } return this; }, /** * Returns true if row has focused class * * @return Boolean */ hasFocus: function () { return this.$el.hasClass(this.classNames.FOCUSED); }, /** * Focus specified field (by name or id - first argument), first field with an error or first field (DOM order) * * @param name * * @return EditRow */ focus: function (name) { var $focus; var $error; this.enable(); if (name) { $focus = this.$el.find(this.fieldFocusSelector(name)); } else { $error = this.$el.find(this.classNames.ERROR + ':first'); if ($error.length === 0) { $focus = this.$el.find(':input:text:first'); } else { $focus = $error.parent().find(':input'); } } this.$el.addClass(this.classNames.FOCUSED); $focus.focus().trigger('select'); return this; }, /** * Disables all fields * * @return EditRow */ disable: function () { var $replacementSubmit; var $submit; // firefox does not allow you to submit a form if there are 2 or more submit buttons in a form, even if all but // one is disabled. It also does not let you change the type="submit' to type="button". Therfore he lies the hack. if (isFirefox) { $submit = this.$el.find(':submit'); if ($submit.length) { $replacementSubmit = $( "<input type='submit' class='" + this.classNames.SUBMIT + "' />" ) .addClass($submit.attr('class')) .val($submit.val()) .data(this.dataKeys.ENABLED_SUBMIT, $submit); $submit.replaceWith($replacementSubmit); } } this.$el.addClass(this.classNames.DISABLED).find(':submit').prop('disabled', true); return this; }, /** * Enables all fields * * @return EditRow */ enable: function () { var $placeholderSubmit; var $submit; // firefox does not allow you to submit a form if there are 2 or more submit buttons in a form, even if all but // one is disabled. It also does not let you change the type="submit' to type="button". Therfore he lies the hack. if (isFirefox) { $placeholderSubmit = this.$el.find(this.classNames.SUBMIT); $submit = $placeholderSubmit.data(this.dataKeys.ENABLED_SUBMIT); if ($submit && $placeholderSubmit.length) { $placeholderSubmit.replaceWith($submit); } } this.$el.removeClass(this.classNames.DISABLED).find(':submit').prop('disabled', false); return this; }, /** * Shows loading indicator * * @return EditRow */ showLoading: function () { appendStatusSpinner(this.$el); return this; }, /** * Hides loading indicator * * @return EditRow */ hideLoading: function () { removeStatusSpinner(this.$el); return this; }, /** * If any of the fields have changed * * @return {Boolean} */ hasUpdates: function () { return !!this.mapSubmitParams(this.serializeObject()); }, /** * Serializes the view into model representation. * Default implementation uses simple jQuery plugin to serialize form fields into object * * @return Object */ serializeObject: function () { var $el = this.$el; return $el.serializeObject ? $el.serializeObject() : $el.serialize(); }, mapSubmitParams: function (params) { return this.model.changedAttributes(params); }, /** * Handle submission of new entries and editing of old. * * @param {Boolean} focusUpdated - flag of whether to focus read-only view after succssful submission * * @return EditRow */ submit: function (focusUpdated) { var instance = this; var values; // IE doesnt like it when the focused element is removed if (document.activeElement !== window) { $(document.activeElement).blur(); } if (this.isUpdateMode) { values = this.mapSubmitParams(this.serializeObject()); // serialize form fields into JSON if (!values) { return instance.trigger(instance._event.CANCEL); } } else { this.model.clear(); values = this.mapSubmitParams(this.serializeObject()); // serialize form fields into JSON } this.trigger(this._event.SUBMIT_STARTED); /* Attempt to add to server model. If fail delegate to createView to render errors etc. Otherwise, add a new model to this._models and render a row to represent it. */ this.model.save(values, { success: function () { if (instance.isUpdateMode) { instance.trigger(instance._event.UPDATED, instance.model, focusUpdated); } else { instance.trigger(instance._event.CREATED, instance.model.toJSON()); instance.model = new instance._modelClass(); // reset instance.render({ errors: {}, values: {} }); // pulls in instance's model for create row instance.trigger(instance._event.FOCUS); } instance.trigger(instance._event.SUBMIT_FINISHED); }, error: function (model, data, xhr) { if (xhr.status === 400) { instance.renderErrors(data.errors); instance.trigger(instance._event.VALIDATION_ERROR, data.errors); } instance.trigger(instance._event.SUBMIT_FINISHED); }, silent: true, }); return this; }, /** * Render an error message * * @param msg * * @return {jQuery} */ renderError: function (name, msg) { return $('<div />').attr('data-field', name).addClass(this.classNames.ERROR).text(msg); }, /** * Render and append error messages. The property name will be matched to the input name to determine which cell to * append the error message to. If this does not meet your needs please extend this method. * * @param errors */ renderErrors: function (errors) { var instance = this; this.$('.' + this.classNames.ERROR).remove(); // avoid duplicates if (errors) { $.each(errors, function (name, msg) { instance.$el .find("[name='" + name + "']") .closest('td') .append(instance.renderError(name, msg)); }); } return this; }, /** * Handles rendering of row * * @param {Object} renderData * ... {Object} vales - Values of fields */ render: function (renderData) { var instance = this; this.$el.empty(); if (this.allowReorder) { $('<td class="' + this.classNames.ORDER + '" />') .append(this.renderDragHandle()) .appendTo(instance.$el); } $.each(this.columns, function (i, column) { var contents; var $cell; var value = renderData.values[column.id]; var args = [ { name: column.id, ariaLabel: column.inputAriaLabel ? column.inputAriaLabel : column.header, value: value, allowEdit: column.allowEdit, }, renderData.values, instance.model, ]; if (value) { instance.$el.attr('data-' + column.id, value); // helper for webdriver testing } if (instance.isCreateRow && column.createView) { // TODO AUI-1058 - The row's model should be guaranteed to be in the correct state by this point. contents = new column.createView({ model: instance.model, }).render(args[0]); } else if (column.editView) { contents = new column.editView({ model: instance.model, }).render(args[0]); } else { contents = instance.defaultColumnRenderer.apply(instance, args); } $cell = $('<td />'); if (typeof contents === 'object' && contents.done) { contents.done(function (contents) { $cell.append(contents); }); } else { $cell.append(contents); } if (column.styleClass) { $cell.addClass(column.styleClass); } $cell.appendTo(instance.$el); }); this.$el .append(this.renderOperations(renderData.update, renderData.values)) // add submit/cancel buttons .addClass(this.classNames.ROW + ' ' + this.classNames.EDIT_ROW); this.trigger(this._event.RENDER, this.$el, renderData.values); this.$el.trigger(this._event.CONTENT_REFRESHED, [this.$el]); return this; }, /** * Gets markup for add/update and cancel buttons * * @param {Boolean} update */ renderOperations: function (update) { var $operations = $('<td class="aui-restfultable-operations" />'); if (update) { $operations .append( $('<input class="aui-button aui-button-primary" type="submit" />').attr({ accesskey: this.submitAccessKey, value: I18n.getText('aui.words.update'), }) ) .append( $('<a class="aui-button aui-button-link" href="#" />') .addClass(this.classNames.CANCEL) .text(I18n.getText('aui.words.cancel')) .attr({ accesskey: this.cancelAccessKey, }) ); } else { $operations.append( $('<input class="aui-button aui-button-primary" type="submit" />').attr({ accesskey: this.submitAccessKey, value: I18n.getText('aui.words.add'), }) ); } return $operations.add($(`<td class="${this.classNames.STATUS}" />`)); }, });