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 ```` tag. Useful in conjunction with Django's ``unordered_list`` template filter. Defaults to ``False``. + + .. attribute:: form_field + + A form field used for inline editing of the column. A django + forms.Field can be used or django form.Widget can be used. + + Example: ``form_field=forms.CharField(required=True)``. + Defaults to ``None``. + + .. attribute:: form_field_attributes + + The additional html attributes that will be rendered to form_field. + Example: ``form_field_attributes={'class': 'bold_input_field'}``. + Defaults to ``None``. + + .. attribute:: update_action + + The class that inherits from tables.actions.UpdateAction, update_cell + method takes care of saving inline edited data. The tables.base.Row + get_data method needs to be connected to table for obtaining the data. + Example: ``update_action=UpdateCell``. + Defaults to ``None``. """ summation_methods = { "sum": sum, @@ -210,7 +234,10 @@ class Column(html.HTMLElement): link=None, allowed_data_types=[], hidden=False, attrs=None, status=False, status_choices=None, display_choices=None, empty_value=None, filters=None, classes=None, summation=None, - auto=None, truncate=None, link_classes=None, wrap_list=False): + auto=None, truncate=None, link_classes=None, wrap_list=False, + form_field=None, form_field_attributes=None, + update_action=None): + self.classes = list(classes or getattr(self, "classes", [])) super(Column, self).__init__() self.attrs.update(attrs or {}) @@ -242,6 +269,9 @@ class Column(html.HTMLElement): self.truncate = truncate self.link_classes = link_classes or [] self.wrap_list = wrap_list + self.form_field = form_field + self.form_field_attributes = form_field_attributes or {} + self.update_action = update_action if status_choices: self.status_choices = status_choices @@ -426,9 +456,17 @@ class Row(html.HTMLElement): String that is used for the query parameter key to request AJAX updates. Generally you won't need to change this value. Default: ``"row_update"``. + + .. attribute:: ajax_cell_action_name + + String that is used for the query parameter key to request AJAX + updates of cell. Generally you won't need to change this value. + It is also used for inline edit of the cell. + Default: ``"cell_update"``. """ ajax = False ajax_action_name = "row_update" + ajax_cell_action_name = "cell_update" def __init__(self, table, datum=None): super(Row, self).__init__() @@ -466,14 +504,40 @@ class Row(html.HTMLElement): widget = forms.CheckboxInput(check_test=lambda value: False) # Convert value to string to avoid accidental type conversion data = widget.render('object_ids', - unicode(table.get_object_id(datum))) + unicode(table.get_object_id(datum)), + {'class': 'table-row-multi-select'}) + table._data_cache[column][table.get_object_id(datum)] = data + elif column.auto == "form_field": + widget = column.form_field + if issubclass(widget.__class__, forms.Field): + widget = widget.widget + + widget_name = "%s__%s" % \ + (column.name, + unicode(table.get_object_id(datum))) + + # Create local copy of attributes, so it don't change column + # class form_field_attributes + form_field_attributes = {} + form_field_attributes.update(column.form_field_attributes) + # Adding id of the input so it pairs with label correctly + form_field_attributes['id'] = widget_name + + data = widget.render(widget_name, + column.get_data(datum), + form_field_attributes) table._data_cache[column][table.get_object_id(datum)] = data elif column.auto == "actions": data = table.render_row_actions(datum) table._data_cache[column][table.get_object_id(datum)] = data else: data = column.get_data(datum) + cell = Cell(datum, data, column, self) + if cell.inline_edit_available: + cell.attrs['data-cell-name'] = column.name + cell.attrs['data-update-url'] = cell.get_ajax_update_url() + cells.append((column.name or column.auto, cell)) self.cells = SortedDict(cells) @@ -483,6 +547,8 @@ class Row(html.HTMLElement): self.attrs['data-update-url'] = self.get_ajax_update_url() self.classes.append("ajax-update") + self.attrs['data-object-id'] = table.get_object_id(datum) + # Add the row's status class and id to the attributes to be rendered. self.classes.append(self.status_class) id_vals = {"table": self.table.name, @@ -553,12 +619,22 @@ class Cell(html.HTMLElement): self.column = column self.row = row self.wrap_list = column.wrap_list + self.inline_edit_available = self.column.update_action is not None + # initialize the update action if available + if self.inline_edit_available: + self.update_action = self.column.update_action() + self.inline_edit_mod = False def __repr__(self): return '<%s: %s, %s>' % (self.__class__.__name__, self.column.name, self.row.id) + @property + def id(self): + return ("%s__%s" % (self.column.name, + unicode(self.row.table.get_object_id(self.datum)))) + @property def value(self): """Returns a formatted version of the data for final output. @@ -631,8 +707,35 @@ class Cell(html.HTMLElement): classes = set(column_class_string.split(" ")) if self.column.status: classes.add(self.get_status_class(self.status)) + + if self.inline_edit_available: + classes.add("inline_edit_available") + return list(classes) + def get_ajax_update_url(self): + column = self.column + table_url = column.table.get_absolute_url() + params = urlencode({"table": column.table.name, + "action": self.row.ajax_cell_action_name, + "obj_id": column.table.get_object_id(self.datum), + "cell_name": column.name}) + return "%s?%s" % (table_url, params) + + @property + def update_allowed(self): + """Determines whether update of given cell is allowed. + + Calls allowed action of defined UpdateAction of the Column. + """ + return self.update_action.allowed(self.column.table.request, + self.datum, + self) + + def render(self): + return render_to_string("horizon/common/_data_table_cell.html", + {"cell": self}) + class DataTableOptions(object): """Contains options for :class:`.DataTable` objects. @@ -1224,6 +1327,11 @@ class DataTable(object): return HttpResponse(new_row.render()) else: return HttpResponse(status=error.status_code) + elif new_row.ajax_cell_action_name == action_name: + # inline edit of the cell actions + return self.inline_edit_handle(request, table_name, + action_name, obj_id, + new_row) preemptive_actions = [action for action in self.base_actions.values() if action.preempt] @@ -1235,6 +1343,90 @@ class DataTable(object): return handled return None + def inline_edit_handle(self, request, table_name, action_name, obj_id, + new_row): + """Inline edit handler. + + Showing form or handling update by POST of the cell. + """ + try: + cell_name = request.GET['cell_name'] + datum = new_row.get_data(request, obj_id) + # TODO(lsmola) extract load cell logic to Cell and load + # only 1 cell. This is kind of ugly. + if request.GET.get('inline_edit_mod') == "true": + new_row.table.columns[cell_name].auto = "form_field" + inline_edit_mod = True + else: + inline_edit_mod = False + + # Load the cell and set the inline_edit_mod. + new_row.load_cells(datum) + cell = new_row.cells[cell_name] + cell.inline_edit_mod = inline_edit_mod + + # If not allowed, neither edit mod or updating is allowed. + if not cell.update_allowed: + datum_display = (self.get_object_display(datum) or + _("N/A")) + LOG.info('Permission denied to %s: "%s"' % + ("Update Action", datum_display)) + return HttpResponse(status=401) + # If it is post request, we are updating the cell. + if request.method == "POST": + return self.inline_update_action(request, + datum, + cell, + obj_id, + cell_name) + + error = False + except Exception: + datum = None + error = exceptions.handle(request, ignore=True) + if request.is_ajax(): + if not error: + return HttpResponse(cell.render()) + else: + return HttpResponse(status=error.status_code) + + def inline_update_action(self, request, datum, cell, obj_id, cell_name): + """Handling update by POST of the cell. + """ + new_cell_value = request.POST.get( + cell_name + '__' + obj_id, None) + if issubclass(cell.column.form_field.__class__, + forms.Field): + try: + # using Django Form Field to parse the + # right value from POST and to validate it + new_cell_value = ( + cell.column.form_field.clean( + new_cell_value)) + cell.update_action.action( + self.request, datum, obj_id, cell_name, new_cell_value) + response = { + 'status': 'updated', + 'message': '' + } + return HttpResponse( + json.dumps(response), + status=200, + content_type="application/json") + + except core_exceptions.ValidationError: + # if there is a validation error, I will + # return the message to the client + exc_type, exc_value, exc_traceback = ( + sys.exc_info()) + response = { + 'status': 'validation_error', + 'message': ' '.join(exc_value.messages)} + return HttpResponse( + json.dumps(response), + status=400, + content_type="application/json") + def maybe_handle(self): """Determine whether the request should be handled by any action on this table after data has been loaded. diff --git a/horizon/templates/horizon/_scripts.html b/horizon/templates/horizon/_scripts.html index 5fb0c73d43..c77e393d08 100644 --- a/horizon/templates/horizon/_scripts.html +++ b/horizon/templates/horizon/_scripts.html @@ -36,6 +36,7 @@ + diff --git a/horizon/templates/horizon/common/_data_table_cell.html b/horizon/templates/horizon/common/_data_table_cell.html new file mode 100644 index 0000000000..7a53eba107 --- /dev/null +++ b/horizon/templates/horizon/common/_data_table_cell.html @@ -0,0 +1,37 @@ +{% if cell.inline_edit_mod and cell.update_allowed %} + +
+
+
+ {{ cell.value }} + {% if cell.column.form_field.label %} + + {% endif %} +
+
+ + +
+
+
+ +{% else %} + {% if cell.inline_edit_available and cell.update_allowed %} + +
+
+ {%if cell.wrap_list %}
    {% endif %}{{ cell.value }}{%if cell.wrap_list %}
{% endif %} +
+
+ +
+
+
+ + {% else %} + {{ cell.value }} + {% endif %} +{% endif %} diff --git a/horizon/templates/horizon/common/_data_table_row.html b/horizon/templates/horizon/common/_data_table_row.html index d6df2ffab5..6cc9648486 100644 --- a/horizon/templates/horizon/common/_data_table_row.html +++ b/horizon/templates/horizon/common/_data_table_row.html @@ -1,3 +1,7 @@ - {% for cell in row %}{%if cell.wrap_list %}
    {% endif %}{{ cell.value }}{%if cell.wrap_list %}
{% endif %}{% endfor %} + {% spaceless %} + {% for cell in row %} + {% include "horizon/common/_data_table_cell.html" %} + {% endfor %} + {% endspaceless %} diff --git a/horizon/test/tests/tables.py b/horizon/test/tests/tables.py index 0d1a460622..db2f0d5597 100644 --- a/horizon/test/tests/tables.py +++ b/horizon/test/tests/tables.py @@ -15,6 +15,7 @@ # under the License. from django.core.urlresolvers import reverse # noqa +from django import forms from django import http from django import shortcuts @@ -145,6 +146,19 @@ class MyFilterAction(tables.FilterAction): return filter(comp, objs) +class MyUpdateAction(tables.UpdateAction): + def allowed(self, *args): + return True + + def update_cell(self, *args): + pass + + +class MyUpdateActionNotAllowed(MyUpdateAction): + def allowed(self, *args): + return False + + def get_name(obj): return "custom %s" % obj.name @@ -155,7 +169,12 @@ def get_link(obj): class MyTable(tables.DataTable): id = tables.Column('id', hidden=True, sortable=False) - name = tables.Column(get_name, verbose_name="Verbose Name", sortable=True) + name = tables.Column(get_name, + verbose_name="Verbose Name", + sortable=True, + form_field=forms.CharField(required=True), + form_field_attributes={'class': 'test'}, + update_action=MyUpdateAction) value = tables.Column('value', sortable=True, link='http://example.com/', @@ -178,6 +197,20 @@ class MyTable(tables.DataTable): row_actions = (MyAction, MyLinkAction, MyBatchAction, MyToggleAction) +class MyTableNotAllowedInlineEdit(MyTable): + name = tables.Column(get_name, + verbose_name="Verbose Name", + sortable=True, + form_field=forms.CharField(required=True), + form_field_attributes={'class': 'test'}, + update_action=MyUpdateActionNotAllowed) + + class Meta: + name = "my_table" + columns = ('id', 'name', 'value', 'optional', 'status') + row_class = MyRow + + class NoActionsTable(tables.DataTable): id = tables.Column('id') @@ -238,6 +271,11 @@ class DataTableTests(test.TestCase): self.assertEqual(actions.auto, "actions") self.assertEqual(actions.get_final_attrs().get('class', ""), "actions_column") + # In-line edit action on column. + name_column = self.table.columns['name'] + self.assertEqual(name_column.update_action, MyUpdateAction) + self.assertEqual(name_column.form_field.__class__, forms.CharField) + self.assertEqual(name_column.form_field_attributes, {'class': 'test'}) def test_table_force_no_multiselect(self): class TempTable(MyTable): @@ -263,6 +301,22 @@ class DataTableTests(test.TestCase): ['', '']) + def test_table_natural_no_inline_editing(self): + class TempTable(MyTable): + name = tables.Column(get_name, + verbose_name="Verbose Name", + sortable=True) + + class Meta: + name = "my_table" + columns = ('id', 'name', 'value', 'optional', 'status') + + self.table = TempTable(self.request, TEST_DATA_2) + name_column = self.table.columns['name'] + self.assertEqual(name_column.update_action, None) + self.assertEqual(name_column.form_field, None) + self.assertEqual(name_column.form_field_attributes, {}) + def test_table_natural_no_actions_column(self): class TempTable(MyTable): class Meta: @@ -445,6 +499,172 @@ class DataTableTests(test.TestCase): resp = http.HttpResponse(table_actions) self.assertContains(resp, "table_search", 0) + def test_inline_edit_available_cell_rendering(self): + self.table = MyTable(self.request, TEST_DATA_2) + row = self.table.get_rows()[0] + name_cell = row.cells['name'] + + # Check if in-line edit is available in the cell, + # but is not in inline_edit_mod. + self.assertEqual(name_cell.inline_edit_available, + True) + self.assertEqual(name_cell.inline_edit_mod, + False) + + # Check if is cell is rendered correctly. + name_cell_rendered = name_cell.render() + resp = http.HttpResponse(name_cell_rendered) + + self.assertContains(resp, '', + count=1, html=True) + + self.assertContains(resp, '', + count=1, html=True) + self.assertContains(resp, + '', + count=1, html=True) + + def test_inline_edit_mod_textarea(self): + class TempTable(MyTable): + name = tables.Column(get_name, + verbose_name="Verbose Name", + sortable=True, + form_field=forms.CharField( + widget=forms.Textarea(), + required=False), + form_field_attributes={'class': 'test'}, + update_action=MyUpdateAction) + + class Meta: + name = "my_table" + columns = ('id', 'name', 'value', 'optional', 'status') + + self.table = TempTable(self.request, TEST_DATA_2) + name_col = self.table.columns['name'] + name_col.auto = "form_field" + + row = self.table.get_rows()[0] + name_cell = row.cells['name'] + name_cell.inline_edit_mod = True + + # Check if is cell is rendered correctly. + name_cell_rendered = name_cell.render() + resp = http.HttpResponse(name_cell_rendered) + + self.assertContains(resp, + '', + count=1, html=True) + def test_table_actions(self): # Single object action action_string = "my_table__delete__1" @@ -603,6 +823,121 @@ class DataTableTests(test.TestCase): self.assertEqual(unicode(row_actions[0].verbose_name), "Delete Me") self.assertEqual(unicode(row_actions[1].verbose_name), "Log In") + def test_inline_edit_update_action_get_non_ajax(self): + # Non ajax inline edit request should return None. + url = ('/my_url/?action=cell_update' + '&table=my_table&cell_name=name&obj_id=1') + req = self.factory.get(url, {}) + self.table = MyTable(req, TEST_DATA_2) + handled = self.table.maybe_preempt() + # Checking the response header. + self.assertEqual(handled, None) + + def test_inline_edit_update_action_get(self): + # Get request should return td field with data. + url = ('/my_url/?action=cell_update' + '&table=my_table&cell_name=name&obj_id=1') + req = self.factory.get(url, {}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest') + self.table = MyTable(req, TEST_DATA_2) + handled = self.table.maybe_preempt() + # Checking the response header. + self.assertEqual(handled.status_code, 200) + # Checking the response content. + resp = handled + self.assertContains(resp, '', + count=1, html=True) + + self.assertContains(resp, '