diff --git a/doc/source/ref/tables.rst b/doc/source/ref/tables.rst index 39f596ce19..2ee75dca36 100644 --- a/doc/source/ref/tables.rst +++ b/doc/source/ref/tables.rst @@ -76,6 +76,9 @@ Actions .. autoclass:: DeleteAction :members: +.. autoclass:: UpdateAction + :members: + Class-Based Views ================= diff --git a/doc/source/topics/tables.rst b/doc/source/topics/tables.rst index bd00a8d238..a24a0517c2 100644 --- a/doc/source/topics/tables.rst +++ b/doc/source/topics/tables.rst @@ -246,3 +246,138 @@ So it's enough to just import and use them, e.g. :: # code omitted filters=(parse_isotime, timesince) + + +Inline editing +============== + +Table cells can be easily upgraded with in-line editing. With use of +django.form.Field, we are able to run validations of the field and correctly +parse the data. The updating process is fully encapsulated into table +functionality, communication with the server goes through AJAX in JSON format. +The javacript wrapper for inline editing allows each table cell that has +in-line editing available to: + #. Refresh itself with new data from the server. + #. Display in edit mod. + #. Send changed data to server. + #. Display validation errors. + +There are basically 3 things that need to be defined in the table in order +to enable in-line editing. + +Fetching the row data +--------------------- + +Defining an ``get_data`` method in a class inherited from ``tables.Row``. +This method takes care of fetching the row data. This class has to be then +defined in the table Meta class as ``row_class = UpdateRow``. + +Example:: + + class UpdateRow(tables.Row): + # this method is also used for automatic update of the row + ajax = True + + def get_data(self, request, project_id): + # getting all data of all row cells + project_info = api.keystone.tenant_get(request, project_id, + admin=True) + return project_info + +Updating changed cell data +-------------------------- + +Define an ``update_cell`` method in the class inherited from +``tables.UpdateAction``. This method takes care of saving the data of the +table cell. There can be one class for every cell thanks to the +``cell_name`` parameter. This class is then defined in tables column as +``update_action=UpdateCell``, so each column can have its own updating +method. + +Example:: + + class UpdateCell(tables.UpdateAction): + def allowed(self, request, project, cell): + # Determines whether given cell or row will be inline editable + # for signed in user. + return api.keystone.keystone_can_edit_project() + + def update_cell(self, request, project_id, cell_name, new_cell_value): + # in-line update project info + try: + project_obj = datum + # updating changed value by new value + setattr(project_obj, cell_name, new_cell_value) + + # sending new attributes back to API + api.keystone.tenant_update( + request, + project_id, + name=project_obj.name, + description=project_obj.description, + enabled=project_obj.enabled) + + except Conflict: + # Validation error for naming conflict, raised when user + # choose the existing name. We will raise a + # ValidationError, that will be sent back to the client + # browser and shown inside of the table cell. + message = _("This name is already taken.") + raise ValidationError(message) + except: + # Other exception of the API just goes through standard + # channel + exceptions.handle(request, ignore=True) + return False + return True + +Defining a form_field for each Column that we want to be in-line edited. +------------------------------------------------------------------------ + +Form field should be ``django.form.Field`` instance, so we can use django +validations and parsing of the values sent by POST (in example validation +``required=True`` and correct parsing of the checkbox value from the POST +data). + +Form field can be also ``django.form.Widget`` class, if we need to just +display the form widget in the table and we don't need Field functionality. + +Then connecting ``UpdateRow`` and ``UpdateCell`` classes to the table. + +Example:: + + class TenantsTable(tables.DataTable): + # Adding html text input for inline editing, with required validation. + # HTML form input will have a class attribute tenant-name-input, we + # can define here any HTML attribute we need. + name = tables.Column('name', verbose_name=_('Name'), + form_field=forms.CharField(required=True), + form_field_attributes={'class':'tenant-name-input'}, + update_action=UpdateCell) + + # Adding html textarea without required validation. + description = tables.Column(lambda obj: getattr(obj, 'description', None), + verbose_name=_('Description'), + form_field=forms.CharField( + widget=forms.Textarea(), + required=False), + update_action=UpdateCell) + + # Id will not be inline edited. + id = tables.Column('id', verbose_name=_('Project ID')) + + # Adding html checkbox, that will be shown inside of the table cell with + # label + enabled = tables.Column('enabled', verbose_name=_('Enabled'), status=True, + form_field=forms.BooleanField( + label=_('Enabled'), + required=False), + update_action=UpdateCell) + + class Meta: + name = "tenants" + verbose_name = _("Projects") + # Connection to UpdateRow, so table can fetch row data based on + # their primary key. + row_class = UpdateRow + diff --git a/horizon/static/horizon/js/horizon.tables.js b/horizon/static/horizon/js/horizon.tables.js index dc01498769..b652cb347d 100644 --- a/horizon/static/horizon/js/horizon.tables.js +++ b/horizon/static/horizon/js/horizon.tables.js @@ -76,9 +76,9 @@ horizon.datatables = { // Only replace row if the html content has changed if($new_row.html() != $row.html()) { - if($row.find(':checkbox').is(':checked')) { + if($row.find('.table-row-multi-select:checkbox').is(':checked')) { // Preserve the checkbox if it's already clicked - $new_row.find(':checkbox').prop('checked', true); + $new_row.find('.table-row-multi-select:checkbox').prop('checked', true); } $row.replaceWith($new_row); // Reset tablesorter's data cache. @@ -112,7 +112,7 @@ horizon.datatables = { validate_button: function () { // Disable form button if checkbox are not checked $("form").each(function (i) { - var checkboxes = $(this).find(":checkbox"); + var checkboxes = $(this).find(".table-row-multi-select:checkbox"); if(!checkboxes.length) { // Do nothing if no checkboxes in this form return; @@ -142,7 +142,7 @@ horizon.datatables.confirm = function (action) { if ($("#"+closest_table_id+" tr[data-display]").length > 0) { if($(action).closest("div").hasClass("table_actions")) { // One or more checkboxes selected - $("#"+closest_table_id+" tr[data-display]").has(":checkbox:checked").each(function() { + $("#"+closest_table_id+" tr[data-display]").has(".table-row-multi-select:checkbox:checked").each(function() { name_array.push(" \"" + $(this).attr("data-display") + "\""); }); name_array.join(", "); @@ -290,8 +290,8 @@ $(parent).find("table.datatable").each(function () { horizon.datatables.add_table_checkboxes = function(parent) { $(parent).find('table thead .multi_select_column').each(function(index, thead) { - if (!$(thead).find(':checkbox').length && - $(thead).parents('table').find('tbody :checkbox').length) { + if (!$(thead).find('.table-row-multi-select:checkbox').length && + $(thead).parents('table').find('tbody .table-row-multi-select:checkbox').length) { $(thead).append(''); } }); @@ -377,24 +377,24 @@ horizon.addInitFunction(function() { horizon.datatables.update_footer_count($(el), 0); }); // Bind the "select all" checkbox action. - $('div.table_wrapper, #modal_wrapper').on('click', 'table thead .multi_select_column :checkbox', function(evt) { + $('div.table_wrapper, #modal_wrapper').on('click', 'table thead .multi_select_column .table-row-multi-select:checkbox', function(evt) { var $this = $(this), $table = $this.closest('table'), is_checked = $this.prop('checked'), - checkboxes = $table.find('tbody :visible:checkbox'); + checkboxes = $table.find('tbody .table-row-multi-select:visible:checkbox'); checkboxes.prop('checked', is_checked); }); // Change "select all" checkbox behaviour while any checkbox is checked/unchecked. - $("div.table_wrapper, #modal_wrapper").on("click", 'table tbody :checkbox', function (evt) { + $("div.table_wrapper, #modal_wrapper").on("click", 'table tbody .table-row-multi-select:checkbox', function (evt) { var $table = $(this).closest('table'); - var $multi_select_checkbox = $table.find('thead .multi_select_column :checkbox'); - var any_unchecked = $table.find("tbody :checkbox").not(":checked"); + var $multi_select_checkbox = $table.find('thead .multi_select_column .table-row-multi-select:checkbox'); + var any_unchecked = $table.find("tbody .table-row-multi-select:checkbox").not(":checked"); $multi_select_checkbox.prop('checked', any_unchecked.length === 0); }); // Enable dangerous buttons only if one or more checkbox is checked. - $("div.table_wrapper, #modal_wrapper").on("click", ':checkbox', function (evt) { + $("div.table_wrapper, #modal_wrapper").on("click", '.table-row-multi-select:checkbox', function (evt) { var $form = $(this).closest("form"); - var any_checked = $form.find("tbody :checkbox").is(":checked"); + var any_checked = $form.find("tbody .table-row-multi-select:checkbox").is(":checked"); if(any_checked) { $form.find(".table_actions button.btn-danger").removeClass("disabled"); }else { diff --git a/horizon/static/horizon/js/horizon.tables_inline_edit.js b/horizon/static/horizon/js/horizon.tables_inline_edit.js new file mode 100644 index 0000000000..93a34c534e --- /dev/null +++ b/horizon/static/horizon/js/horizon.tables_inline_edit.js @@ -0,0 +1,271 @@ +horizon.inline_edit = { + get_cell_id: function (td_element) { + return (td_element.parents("tr").first().data("object-id") + + "__" + td_element.data("cell-name")); + }, + get_object_container: function (td_element) { + // global cell object container + if (!window.cell_object_container) { + window.cell_object_container = new Array(); + } + return window.cell_object_container; + }, + get_cell_object: function (td_element) { + var cell_id = horizon.inline_edit.get_cell_id(td_element); + var id = "cell__" + cell_id; + var container = horizon.inline_edit.get_object_container(td_element); + if (container && container[id]){ + // if cell object exists, I will reuse it + var cell_object = container[id]; + cell_object.reset_with(td_element); + return cell_object; + } else { + // or I will create new cell object + var cell_object = new horizon.inline_edit.Cell(td_element); + // saving cell object to global container + container[id] = cell_object; + return cell_object; + } + }, + Cell: function (td_element){ + var self = this; + + // setting initial attributes + self.reset_with = function(td_element){ + self.td_element = td_element; + self.form_element = td_element.find("input, textarea"); + self.url = td_element.data('update-url'); + self.inline_edit_mod = false; + self.successful_update = false; + }; + self.reset_with(td_element); + + self.refresh = function () { + horizon.ajax.queue({ + url: self.url, + data: {'inline_edit_mod': self.inline_edit_mod}, + beforeSend: function () { + self.start_loading(); + }, + complete: function () { + // Bug in Jquery tool-tip, if I hover tool-tip, then confirm the field with + // enter and the cell is reloaded, tool-tip stays. So just to be sure, I am + // removing tool-tips manually + $(".tooltip.fade.top.in").remove(); + self.stop_loading(); + + if (self.successful_update) { + // if cell was updated successfully, I will show fading check sign + var success = $('
'); + self.td_element.find('.inline-edit-status').append(success); + + var background_color = self.td_element.css('background-color'); + + // edit pencil will disappear and appear again once the check sign has faded + // also green background will disappear + self.td_element.addClass("no-transition"); + self.td_element.addClass("success"); + self.td_element.removeClass("no-transition"); + + self.td_element.removeClass("inline_edit_available"); + + success.fadeOut(1300, function () { + self.td_element.addClass("inline_edit_available"); + self.td_element.removeClass("success"); + }); + } + }, + error: function(jqXHR, status, errorThrown) { + if (jqXHR.status === 401){ + var redir_url = jqXHR.getResponseHeader("X-Horizon-Location"); + if (redir_url){ + location.href = redir_url; + } else { + horizon.alert("error", gettext("Not authorized to do this operation.")); + } + } + else { + if (!horizon.ajax.get_messages(jqXHR)) { + // Generic error handler. Really generic. + horizon.alert("error", gettext("An error occurred. Please try again later.")); + } + } + }, + success: function (data, textStatus, jqXHR) { + var td_element = $(data); + self.form_element = self.get_form_element(td_element); + + if (self.inline_edit_mod) { + // if cell is in inline edit mode + var table_cell_wrapper = td_element.find(".table_cell_wrapper"); + + width = self.td_element.outerWidth(); + height = self.td_element.outerHeight(); + + td_element.width(width); + td_element.height(height); + td_element.css('margin', 0).css('padding', 0); + table_cell_wrapper.css('margin', 0).css('padding', 0); + + if (self.form_element.attr('type')=='checkbox'){ + var inline_edit_form = td_element.find(".inline-edit-form"); + inline_edit_form.css('padding-top', '11px').css('padding-left', '4px'); + inline_edit_form.width(width - 40); + } else { + // setting CSS of element, so the cell remains the same size in editing mode + self.form_element.width(width - 40); + self.form_element.height(height - 2); + self.form_element.css('margin', 0).css('padding', 0); + } + } + // saving old td_element for cancel and loading purposes + self.cached_presentation_view = self.td_element; + // replacing old td with the new td element returned from the server + self.rewrite_cell(td_element); + // focusing the form element inside the cell + if (self.inline_edit_mod) { + self.form_element.focus(); + } + } + }); + }; + self.update = function(post_data){ + // make the update request + horizon.ajax.queue({ + type: 'POST', + url: self.url, + data: post_data, + beforeSend: function () { + self.start_loading(); + }, + complete: function () { + if (!self.successful_update){ + self.stop_loading(); + } + }, + error: function(jqXHR, status, errorThrown) { + if (jqXHR.status === 400){ + // make place for error icon, only if the error icon is not already present + if (self.td_element.find(".inline-edit-error .error").length <= 0) { + self.form_element.css('padding-left', '20px'); + self.form_element.width(self.form_element.width() - 20); + } + // obtain the error message from response body + error_message = $.parseJSON(jqXHR.responseText).message; + // insert the error icon + var error = $('') + self.td_element.find(".inline-edit-error").html(error); + error.tooltip({'placement':'top'}); + } + else if (jqXHR.status === 401){ + var redir_url = jqXHR.getResponseHeader("X-Horizon-Location"); + if (redir_url){ + location.href = redir_url; + } else { + horizon.alert("error", gettext("Not authorized to do this operation.")); + } + } + else { + if (!horizon.ajax.get_messages(jqXHR)) { + // Generic error handler. Really generic. + horizon.alert("error", gettext("An error occurred. Please try again later.")); + } + } + }, + success: function (data, textStatus, jqXHR) { + // if update was successful + self.successful_update = true; + self.refresh(); + } + }); + }; + self.cancel = function() { + self.rewrite_cell(self.cached_presentation_view); + self.stop_loading(); + }; + self.get_form_element = function(td_element){ + return td_element.find("input, textarea"); + }; + self.rewrite_cell = function(td_element){ + self.td_element.replaceWith(td_element); + self.td_element = td_element; + }; + self.start_loading = function() { + self.td_element.addClass("no-transition"); + + var spinner = $(''); + self.td_element.find('.inline-edit-status').append(spinner); + self.td_element.addClass("loading"); + self.td_element.removeClass("inline_edit_available"); + self.get_form_element(self.td_element).attr("disabled", "disabled"); + }; + self.stop_loading = function() { + self.td_element.find('div.inline-edit-status div.loading').remove(); + self.td_element.removeClass("loading"); + self.td_element.addClass("inline_edit_available"); + self.get_form_element(self.td_element).removeAttr("disabled"); + }; + } +}; + + +horizon.addInitFunction(function() { + $('table').on('click', '.ajax-inline-edit', function (evt) { + var $this = $(this); + var td_element = $this.parents('td').first(); + + var cell = horizon.inline_edit.get_cell_object(td_element); + cell.inline_edit_mod = true; + cell.refresh(); + + evt.preventDefault(); + }); + + var submit_form = function(evt, el){ + var $submit = $(el); + var td_element = $submit.parents('td').first(); + var post_data = $submit.parents('form').first().serialize(); + + var cell = horizon.inline_edit.get_cell_object(td_element); + cell.update(post_data); + + evt.preventDefault(); + } + + $('table').on('click', '.inline-edit-submit', function (evt) { + submit_form(evt, this); + }); + + $('table').on('keypress', '.inline-edit-form', function (evt) { + if (evt.which == 13 && !evt.shiftKey) { + submit_form(evt, this); + } + }); + + $('table').on('click', '.inline-edit-cancel', function (evt) { + var $cancel = $(this); + var td_element = $cancel.parents('td').first(); + + var cell = horizon.inline_edit.get_cell_object(td_element); + cell.cancel(); + + evt.preventDefault(); + }); + + $('table').on('mouseenter', '.inline_edit_available', function (evt) { + $(this).find(".table_cell_action").fadeIn(100); + }); + + $('table').on('mouseleave', '.inline_edit_available', function (evt) { + $(this).find(".table_cell_action").fadeOut(200); + }); + + $('table').on('mouseenter', '.table_cell_action', function (evt) { + $(this).addClass("hovered"); + }); + + $('table').on('mouseleave', '.table_cell_action', function (evt) { + $(this).removeClass("hovered"); + }); +}); + diff --git a/horizon/tables/__init__.py b/horizon/tables/__init__.py index 55803026bc..c95deb21e1 100644 --- a/horizon/tables/__init__.py +++ b/horizon/tables/__init__.py @@ -21,6 +21,7 @@ from horizon.tables.actions import DeleteAction # noqa from horizon.tables.actions import FilterAction # noqa from horizon.tables.actions import FixedFilterAction # noqa from horizon.tables.actions import LinkAction # noqa +from horizon.tables.actions import UpdateAction # noqa from horizon.tables.base import Column # noqa from horizon.tables.base import DataTable # noqa from horizon.tables.base import Row # noqa @@ -33,6 +34,7 @@ assert Action assert BatchAction assert DeleteAction assert LinkAction +assert UpdateAction assert FilterAction assert FixedFilterAction assert DataTable diff --git a/horizon/tables/actions.py b/horizon/tables/actions.py index 2e0de3dba6..e624c54764 100644 --- a/horizon/tables/actions.py +++ b/horizon/tables/actions.py @@ -695,3 +695,31 @@ class DeleteAction(BatchAction): classes = super(DeleteAction, self).get_default_classes() classes += ("btn-danger", "btn-delete") return classes + + +class UpdateAction(object): + """A table action for cell updates by inline editing.""" + name = "update" + action_present = _("Update") + action_past = _("Updated") + data_type_singular = "update" + + def action(self, request, datum, obj_id, cell_name, new_cell_value): + self.update_cell(request, datum, obj_id, cell_name, new_cell_value) + + def update_cell(self, request, datum, obj_id, cell_name, new_cell_value): + """Method for saving data of the cell. + + This method must implements saving logic of the inline edited table + cell. + """ + raise NotImplementedError( + "UpdateAction must define a update_cell method.") + + def allowed(self, request, datum, cell): + """Determine whether updating is allowed for the current request. + + This method is meant to be overridden with more specific checks. + Data of the row and of the cell are passed to the method. + """ + return True diff --git a/horizon/tables/base.py b/horizon/tables/base.py index c525cccd02..91f8bdff42 100644 --- a/horizon/tables/base.py +++ b/horizon/tables/base.py @@ -16,11 +16,13 @@ import collections import copy +import json import logging from operator import attrgetter # noqa import sys from django.conf import settings # noqa +from django.core import exceptions as core_exceptions from django.core import urlresolvers from django import forms from django.http import HttpResponse # noqa @@ -177,6 +179,28 @@ class Column(html.HTMLElement): Boolean value indicating whether the contents of this cell should be wrapped in a ``