Inline Table editing
Implementation of inline-table-editing blueprint: - Design from jcoufal is implemented with Bootstrap elements. - Inline edit mode implemented, Django Form Field connected to table logic, using Django Form Widget to render. Table Cell can be updated and refreshed. - Allowed method implemented for checking of cell edit permissions - Validation process is taken from Django Form Field logic, so validation is defined as part of Django Form Field defined in table Column. - Update method of the cell is defined as Table Action. For obtaining row data, UpdateRow functionality is used. - Horizon Tests for inline editing implemented. - Selenium Tests for inline editing of tenants implemented - Documentation written in both ref and topics Change-Id: Ib29b58da71d3a8abc9688bc942fe49917161e97a Implements: blueprint inline-table-editing
This commit is contained in:
parent
7508917199
commit
c77d9074f1
@ -76,6 +76,9 @@ Actions
|
|||||||
.. autoclass:: DeleteAction
|
.. autoclass:: DeleteAction
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
|
.. autoclass:: UpdateAction
|
||||||
|
:members:
|
||||||
|
|
||||||
Class-Based Views
|
Class-Based Views
|
||||||
=================
|
=================
|
||||||
|
|
||||||
|
@ -246,3 +246,138 @@ So it's enough to just import and use them, e.g. ::
|
|||||||
|
|
||||||
# code omitted
|
# code omitted
|
||||||
filters=(parse_isotime, timesince)
|
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
|
||||||
|
|
||||||
|
@ -76,9 +76,9 @@ horizon.datatables = {
|
|||||||
|
|
||||||
// Only replace row if the html content has changed
|
// Only replace row if the html content has changed
|
||||||
if($new_row.html() != $row.html()) {
|
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
|
// 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);
|
$row.replaceWith($new_row);
|
||||||
// Reset tablesorter's data cache.
|
// Reset tablesorter's data cache.
|
||||||
@ -112,7 +112,7 @@ horizon.datatables = {
|
|||||||
validate_button: function () {
|
validate_button: function () {
|
||||||
// Disable form button if checkbox are not checked
|
// Disable form button if checkbox are not checked
|
||||||
$("form").each(function (i) {
|
$("form").each(function (i) {
|
||||||
var checkboxes = $(this).find(":checkbox");
|
var checkboxes = $(this).find(".table-row-multi-select:checkbox");
|
||||||
if(!checkboxes.length) {
|
if(!checkboxes.length) {
|
||||||
// Do nothing if no checkboxes in this form
|
// Do nothing if no checkboxes in this form
|
||||||
return;
|
return;
|
||||||
@ -142,7 +142,7 @@ horizon.datatables.confirm = function (action) {
|
|||||||
if ($("#"+closest_table_id+" tr[data-display]").length > 0) {
|
if ($("#"+closest_table_id+" tr[data-display]").length > 0) {
|
||||||
if($(action).closest("div").hasClass("table_actions")) {
|
if($(action).closest("div").hasClass("table_actions")) {
|
||||||
// One or more checkboxes selected
|
// 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.push(" \"" + $(this).attr("data-display") + "\"");
|
||||||
});
|
});
|
||||||
name_array.join(", ");
|
name_array.join(", ");
|
||||||
@ -290,8 +290,8 @@ $(parent).find("table.datatable").each(function () {
|
|||||||
|
|
||||||
horizon.datatables.add_table_checkboxes = function(parent) {
|
horizon.datatables.add_table_checkboxes = function(parent) {
|
||||||
$(parent).find('table thead .multi_select_column').each(function(index, thead) {
|
$(parent).find('table thead .multi_select_column').each(function(index, thead) {
|
||||||
if (!$(thead).find(':checkbox').length &&
|
if (!$(thead).find('.table-row-multi-select:checkbox').length &&
|
||||||
$(thead).parents('table').find('tbody :checkbox').length) {
|
$(thead).parents('table').find('tbody .table-row-multi-select:checkbox').length) {
|
||||||
$(thead).append('<input type="checkbox">');
|
$(thead).append('<input type="checkbox">');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -377,24 +377,24 @@ horizon.addInitFunction(function() {
|
|||||||
horizon.datatables.update_footer_count($(el), 0);
|
horizon.datatables.update_footer_count($(el), 0);
|
||||||
});
|
});
|
||||||
// Bind the "select all" checkbox action.
|
// 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),
|
var $this = $(this),
|
||||||
$table = $this.closest('table'),
|
$table = $this.closest('table'),
|
||||||
is_checked = $this.prop('checked'),
|
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);
|
checkboxes.prop('checked', is_checked);
|
||||||
});
|
});
|
||||||
// Change "select all" checkbox behaviour while any checkbox is checked/unchecked.
|
// 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 $table = $(this).closest('table');
|
||||||
var $multi_select_checkbox = $table.find('thead .multi_select_column :checkbox');
|
var $multi_select_checkbox = $table.find('thead .multi_select_column .table-row-multi-select:checkbox');
|
||||||
var any_unchecked = $table.find("tbody :checkbox").not(":checked");
|
var any_unchecked = $table.find("tbody .table-row-multi-select:checkbox").not(":checked");
|
||||||
$multi_select_checkbox.prop('checked', any_unchecked.length === 0);
|
$multi_select_checkbox.prop('checked', any_unchecked.length === 0);
|
||||||
});
|
});
|
||||||
// Enable dangerous buttons only if one or more checkbox is checked.
|
// 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 $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) {
|
if(any_checked) {
|
||||||
$form.find(".table_actions button.btn-danger").removeClass("disabled");
|
$form.find(".table_actions button.btn-danger").removeClass("disabled");
|
||||||
}else {
|
}else {
|
||||||
|
271
horizon/static/horizon/js/horizon.tables_inline_edit.js
Normal file
271
horizon/static/horizon/js/horizon.tables_inline_edit.js
Normal file
@ -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 = $('<div class="success"></div>');
|
||||||
|
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 = $('<div title="' + error_message + '" class="error"></div>')
|
||||||
|
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 = $('<div class="loading"></div>');
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -21,6 +21,7 @@ from horizon.tables.actions import DeleteAction # noqa
|
|||||||
from horizon.tables.actions import FilterAction # noqa
|
from horizon.tables.actions import FilterAction # noqa
|
||||||
from horizon.tables.actions import FixedFilterAction # noqa
|
from horizon.tables.actions import FixedFilterAction # noqa
|
||||||
from horizon.tables.actions import LinkAction # 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 Column # noqa
|
||||||
from horizon.tables.base import DataTable # noqa
|
from horizon.tables.base import DataTable # noqa
|
||||||
from horizon.tables.base import Row # noqa
|
from horizon.tables.base import Row # noqa
|
||||||
@ -33,6 +34,7 @@ assert Action
|
|||||||
assert BatchAction
|
assert BatchAction
|
||||||
assert DeleteAction
|
assert DeleteAction
|
||||||
assert LinkAction
|
assert LinkAction
|
||||||
|
assert UpdateAction
|
||||||
assert FilterAction
|
assert FilterAction
|
||||||
assert FixedFilterAction
|
assert FixedFilterAction
|
||||||
assert DataTable
|
assert DataTable
|
||||||
|
@ -695,3 +695,31 @@ class DeleteAction(BatchAction):
|
|||||||
classes = super(DeleteAction, self).get_default_classes()
|
classes = super(DeleteAction, self).get_default_classes()
|
||||||
classes += ("btn-danger", "btn-delete")
|
classes += ("btn-danger", "btn-delete")
|
||||||
return classes
|
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
|
||||||
|
@ -16,11 +16,13 @@
|
|||||||
|
|
||||||
import collections
|
import collections
|
||||||
import copy
|
import copy
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from operator import attrgetter # noqa
|
from operator import attrgetter # noqa
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from django.conf import settings # noqa
|
from django.conf import settings # noqa
|
||||||
|
from django.core import exceptions as core_exceptions
|
||||||
from django.core import urlresolvers
|
from django.core import urlresolvers
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.http import HttpResponse # noqa
|
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
|
Boolean value indicating whether the contents of this cell should be
|
||||||
wrapped in a ``<ul></ul>`` tag. Useful in conjunction with Django's
|
wrapped in a ``<ul></ul>`` tag. Useful in conjunction with Django's
|
||||||
``unordered_list`` template filter. Defaults to ``False``.
|
``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 = {
|
summation_methods = {
|
||||||
"sum": sum,
|
"sum": sum,
|
||||||
@ -210,7 +234,10 @@ class Column(html.HTMLElement):
|
|||||||
link=None, allowed_data_types=[], hidden=False, attrs=None,
|
link=None, allowed_data_types=[], hidden=False, attrs=None,
|
||||||
status=False, status_choices=None, display_choices=None,
|
status=False, status_choices=None, display_choices=None,
|
||||||
empty_value=None, filters=None, classes=None, summation=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", []))
|
self.classes = list(classes or getattr(self, "classes", []))
|
||||||
super(Column, self).__init__()
|
super(Column, self).__init__()
|
||||||
self.attrs.update(attrs or {})
|
self.attrs.update(attrs or {})
|
||||||
@ -242,6 +269,9 @@ class Column(html.HTMLElement):
|
|||||||
self.truncate = truncate
|
self.truncate = truncate
|
||||||
self.link_classes = link_classes or []
|
self.link_classes = link_classes or []
|
||||||
self.wrap_list = wrap_list
|
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:
|
if status_choices:
|
||||||
self.status_choices = 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
|
String that is used for the query parameter key to request AJAX
|
||||||
updates. Generally you won't need to change this value.
|
updates. Generally you won't need to change this value.
|
||||||
Default: ``"row_update"``.
|
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 = False
|
||||||
ajax_action_name = "row_update"
|
ajax_action_name = "row_update"
|
||||||
|
ajax_cell_action_name = "cell_update"
|
||||||
|
|
||||||
def __init__(self, table, datum=None):
|
def __init__(self, table, datum=None):
|
||||||
super(Row, self).__init__()
|
super(Row, self).__init__()
|
||||||
@ -466,14 +504,40 @@ class Row(html.HTMLElement):
|
|||||||
widget = forms.CheckboxInput(check_test=lambda value: False)
|
widget = forms.CheckboxInput(check_test=lambda value: False)
|
||||||
# Convert value to string to avoid accidental type conversion
|
# Convert value to string to avoid accidental type conversion
|
||||||
data = widget.render('object_ids',
|
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
|
table._data_cache[column][table.get_object_id(datum)] = data
|
||||||
elif column.auto == "actions":
|
elif column.auto == "actions":
|
||||||
data = table.render_row_actions(datum)
|
data = table.render_row_actions(datum)
|
||||||
table._data_cache[column][table.get_object_id(datum)] = data
|
table._data_cache[column][table.get_object_id(datum)] = data
|
||||||
else:
|
else:
|
||||||
data = column.get_data(datum)
|
data = column.get_data(datum)
|
||||||
|
|
||||||
cell = Cell(datum, data, column, self)
|
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))
|
cells.append((column.name or column.auto, cell))
|
||||||
self.cells = SortedDict(cells)
|
self.cells = SortedDict(cells)
|
||||||
|
|
||||||
@ -483,6 +547,8 @@ class Row(html.HTMLElement):
|
|||||||
self.attrs['data-update-url'] = self.get_ajax_update_url()
|
self.attrs['data-update-url'] = self.get_ajax_update_url()
|
||||||
self.classes.append("ajax-update")
|
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.
|
# Add the row's status class and id to the attributes to be rendered.
|
||||||
self.classes.append(self.status_class)
|
self.classes.append(self.status_class)
|
||||||
id_vals = {"table": self.table.name,
|
id_vals = {"table": self.table.name,
|
||||||
@ -553,12 +619,22 @@ class Cell(html.HTMLElement):
|
|||||||
self.column = column
|
self.column = column
|
||||||
self.row = row
|
self.row = row
|
||||||
self.wrap_list = column.wrap_list
|
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):
|
def __repr__(self):
|
||||||
return '<%s: %s, %s>' % (self.__class__.__name__,
|
return '<%s: %s, %s>' % (self.__class__.__name__,
|
||||||
self.column.name,
|
self.column.name,
|
||||||
self.row.id)
|
self.row.id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self):
|
||||||
|
return ("%s__%s" % (self.column.name,
|
||||||
|
unicode(self.row.table.get_object_id(self.datum))))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def value(self):
|
def value(self):
|
||||||
"""Returns a formatted version of the data for final output.
|
"""Returns a formatted version of the data for final output.
|
||||||
@ -631,8 +707,35 @@ class Cell(html.HTMLElement):
|
|||||||
classes = set(column_class_string.split(" "))
|
classes = set(column_class_string.split(" "))
|
||||||
if self.column.status:
|
if self.column.status:
|
||||||
classes.add(self.get_status_class(self.status))
|
classes.add(self.get_status_class(self.status))
|
||||||
|
|
||||||
|
if self.inline_edit_available:
|
||||||
|
classes.add("inline_edit_available")
|
||||||
|
|
||||||
return list(classes)
|
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):
|
class DataTableOptions(object):
|
||||||
"""Contains options for :class:`.DataTable` objects.
|
"""Contains options for :class:`.DataTable` objects.
|
||||||
@ -1224,6 +1327,11 @@ class DataTable(object):
|
|||||||
return HttpResponse(new_row.render())
|
return HttpResponse(new_row.render())
|
||||||
else:
|
else:
|
||||||
return HttpResponse(status=error.status_code)
|
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
|
preemptive_actions = [action for action in
|
||||||
self.base_actions.values() if action.preempt]
|
self.base_actions.values() if action.preempt]
|
||||||
@ -1235,6 +1343,90 @@ class DataTable(object):
|
|||||||
return handled
|
return handled
|
||||||
return None
|
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):
|
def maybe_handle(self):
|
||||||
"""Determine whether the request should be handled by any action on
|
"""Determine whether the request should be handled by any action on
|
||||||
this table after data has been loaded.
|
this table after data has been loaded.
|
||||||
|
@ -36,6 +36,7 @@
|
|||||||
<script src='{{ STATIC_URL }}horizon/js/horizon.modals.js' type='text/javascript' charset='utf-8'></script>
|
<script src='{{ STATIC_URL }}horizon/js/horizon.modals.js' type='text/javascript' charset='utf-8'></script>
|
||||||
<script src='{{ STATIC_URL }}horizon/js/horizon.quota.js' type='text/javascript' charset='utf-8'></script>
|
<script src='{{ STATIC_URL }}horizon/js/horizon.quota.js' type='text/javascript' charset='utf-8'></script>
|
||||||
<script src='{{ STATIC_URL }}horizon/js/horizon.tables.js' type='text/javascript' charset='utf-8'></script>
|
<script src='{{ STATIC_URL }}horizon/js/horizon.tables.js' type='text/javascript' charset='utf-8'></script>
|
||||||
|
<script src='{{ STATIC_URL }}horizon/js/horizon.tables_inline_edit.js' type='text/javascript' charset='utf-8'></script>
|
||||||
<script src='{{ STATIC_URL }}horizon/js/horizon.tabs.js' type='text/javascript' charset='utf-8'></script>
|
<script src='{{ STATIC_URL }}horizon/js/horizon.tabs.js' type='text/javascript' charset='utf-8'></script>
|
||||||
<script src='{{ STATIC_URL }}horizon/js/horizon.templates.js' type='text/javascript' charset='utf-8'></script>
|
<script src='{{ STATIC_URL }}horizon/js/horizon.templates.js' type='text/javascript' charset='utf-8'></script>
|
||||||
<script src='{{ STATIC_URL }}horizon/js/horizon.users.js' type='text/javascript' charset='utf-8'></script>
|
<script src='{{ STATIC_URL }}horizon/js/horizon.users.js' type='text/javascript' charset='utf-8'></script>
|
||||||
|
37
horizon/templates/horizon/common/_data_table_cell.html
Normal file
37
horizon/templates/horizon/common/_data_table_cell.html
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{% if cell.inline_edit_mod and cell.update_allowed %}
|
||||||
|
<td{{ cell.attr_string|safe }}>
|
||||||
|
<div class="table_cell_wrapper">
|
||||||
|
<div class="inline-edit-error"> </div>
|
||||||
|
<div class="inline-edit-form">
|
||||||
|
{{ cell.value }}
|
||||||
|
{% if cell.column.form_field.label %}
|
||||||
|
<label class="inline-edit-label" for="{{ cell.id }}">{{ cell.column.form_field.label }}</label>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="inline-edit-actions">
|
||||||
|
<button class="inline-edit-submit btn btn-primary pull-right"
|
||||||
|
name="action"
|
||||||
|
value="" type="submit">
|
||||||
|
</button>
|
||||||
|
<button class="inline-edit-cancel btn secondary cancel"></button>
|
||||||
|
</div>
|
||||||
|
<div class="inline-edit-status inline-edit-mod"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{% else %}
|
||||||
|
{% if cell.inline_edit_available and cell.update_allowed %}
|
||||||
|
<td{{ cell.attr_string|safe }}>
|
||||||
|
<div class="table_cell_wrapper">
|
||||||
|
<div class="table_cell_data_wrapper">
|
||||||
|
{%if cell.wrap_list %}<ul>{% endif %}{{ cell.value }}{%if cell.wrap_list %}</ul>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="table_cell_action">
|
||||||
|
<button class="ajax-inline-edit btn-edit"></button>
|
||||||
|
</div>
|
||||||
|
<div class="inline-edit-status"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{% else %}
|
||||||
|
<td{{ cell.attr_string|safe }}>{{ cell.value }}</td>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
@ -1,3 +1,7 @@
|
|||||||
<tr{{ row.attr_string|safe }}>
|
<tr{{ row.attr_string|safe }}>
|
||||||
{% for cell in row %}<td{{ cell.attr_string|safe }}>{%if cell.wrap_list %}<ul>{% endif %}{{ cell.value }}{%if cell.wrap_list %}</ul>{% endif %}</td>{% endfor %}
|
{% spaceless %}
|
||||||
|
{% for cell in row %}
|
||||||
|
{% include "horizon/common/_data_table_cell.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endspaceless %}
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
from django.core.urlresolvers import reverse # noqa
|
from django.core.urlresolvers import reverse # noqa
|
||||||
|
from django import forms
|
||||||
from django import http
|
from django import http
|
||||||
from django import shortcuts
|
from django import shortcuts
|
||||||
|
|
||||||
@ -145,6 +146,19 @@ class MyFilterAction(tables.FilterAction):
|
|||||||
return filter(comp, objs)
|
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):
|
def get_name(obj):
|
||||||
return "custom %s" % obj.name
|
return "custom %s" % obj.name
|
||||||
|
|
||||||
@ -155,7 +169,12 @@ def get_link(obj):
|
|||||||
|
|
||||||
class MyTable(tables.DataTable):
|
class MyTable(tables.DataTable):
|
||||||
id = tables.Column('id', hidden=True, sortable=False)
|
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',
|
value = tables.Column('value',
|
||||||
sortable=True,
|
sortable=True,
|
||||||
link='http://example.com/',
|
link='http://example.com/',
|
||||||
@ -178,6 +197,20 @@ class MyTable(tables.DataTable):
|
|||||||
row_actions = (MyAction, MyLinkAction, MyBatchAction, MyToggleAction)
|
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):
|
class NoActionsTable(tables.DataTable):
|
||||||
id = tables.Column('id')
|
id = tables.Column('id')
|
||||||
|
|
||||||
@ -238,6 +271,11 @@ class DataTableTests(test.TestCase):
|
|||||||
self.assertEqual(actions.auto, "actions")
|
self.assertEqual(actions.auto, "actions")
|
||||||
self.assertEqual(actions.get_final_attrs().get('class', ""),
|
self.assertEqual(actions.get_final_attrs().get('class', ""),
|
||||||
"actions_column")
|
"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):
|
def test_table_force_no_multiselect(self):
|
||||||
class TempTable(MyTable):
|
class TempTable(MyTable):
|
||||||
@ -263,6 +301,22 @@ class DataTableTests(test.TestCase):
|
|||||||
['<Column: multi_select>',
|
['<Column: multi_select>',
|
||||||
'<Column: id>'])
|
'<Column: id>'])
|
||||||
|
|
||||||
|
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):
|
def test_table_natural_no_actions_column(self):
|
||||||
class TempTable(MyTable):
|
class TempTable(MyTable):
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -445,6 +499,172 @@ class DataTableTests(test.TestCase):
|
|||||||
resp = http.HttpResponse(table_actions)
|
resp = http.HttpResponse(table_actions)
|
||||||
self.assertContains(resp, "table_search", 0)
|
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, '<td', 1)
|
||||||
|
self.assertContains(resp, 'inline_edit_available', 1)
|
||||||
|
self.assertContains(resp,
|
||||||
|
'data-update-url="?action=cell_update&'
|
||||||
|
'table=my_table&cell_name=name&obj_id=1"',
|
||||||
|
1)
|
||||||
|
self.assertContains(resp, 'table_cell_wrapper', 1)
|
||||||
|
self.assertContains(resp, 'table_cell_data_wrapper', 1)
|
||||||
|
self.assertContains(resp, 'table_cell_action', 1)
|
||||||
|
self.assertContains(resp, 'ajax-inline-edit', 1)
|
||||||
|
|
||||||
|
def test_inline_edit_available_not_allowed_cell_rendering(self):
|
||||||
|
self.table = MyTableNotAllowedInlineEdit(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, '<td', 1)
|
||||||
|
self.assertContains(resp, 'inline_edit_available', 1)
|
||||||
|
self.assertContains(resp,
|
||||||
|
'data-update-url="?action=cell_update&'
|
||||||
|
'table=my_table&cell_name=name&obj_id=1"',
|
||||||
|
1)
|
||||||
|
self.assertContains(resp, 'table_cell_wrapper', 0)
|
||||||
|
self.assertContains(resp, 'table_cell_data_wrapper', 0)
|
||||||
|
self.assertContains(resp, 'table_cell_action', 0)
|
||||||
|
self.assertContains(resp, 'ajax-inline-edit', 0)
|
||||||
|
|
||||||
|
def test_inline_edit_mod_cell_rendering(self):
|
||||||
|
self.table = MyTable(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 in-line edit is available in the cell,
|
||||||
|
# and is in inline_edit_mod, also column auto must be
|
||||||
|
# set as form_field.
|
||||||
|
self.assertEqual(name_cell.inline_edit_available,
|
||||||
|
True)
|
||||||
|
self.assertEqual(name_cell.inline_edit_mod,
|
||||||
|
True)
|
||||||
|
self.assertEqual(name_col.auto,
|
||||||
|
'form_field')
|
||||||
|
|
||||||
|
# Check if is cell is rendered correctly.
|
||||||
|
name_cell_rendered = name_cell.render()
|
||||||
|
resp = http.HttpResponse(name_cell_rendered)
|
||||||
|
|
||||||
|
self.assertContains(resp,
|
||||||
|
'<input class="test" id="name__1" name="name__1"'
|
||||||
|
' type="text" value="custom object_1" />',
|
||||||
|
count=1, html=True)
|
||||||
|
|
||||||
|
self.assertContains(resp, '<td', 1)
|
||||||
|
self.assertContains(resp, 'inline_edit_available', 1)
|
||||||
|
self.assertContains(resp,
|
||||||
|
'data-update-url="?action=cell_update&'
|
||||||
|
'table=my_table&cell_name=name&obj_id=1"',
|
||||||
|
1)
|
||||||
|
self.assertContains(resp, 'table_cell_wrapper', 1)
|
||||||
|
self.assertContains(resp, 'inline-edit-error', 1)
|
||||||
|
self.assertContains(resp, 'inline-edit-form', 1)
|
||||||
|
self.assertContains(resp, 'inline-edit-actions', 1)
|
||||||
|
self.assertContains(resp, 'inline-edit-submit', 1)
|
||||||
|
self.assertContains(resp, 'inline-edit-cancel', 1)
|
||||||
|
|
||||||
|
def test_inline_edit_mod_checkbox_with_label(self):
|
||||||
|
class TempTable(MyTable):
|
||||||
|
name = tables.Column(get_name,
|
||||||
|
verbose_name="Verbose Name",
|
||||||
|
sortable=True,
|
||||||
|
form_field=forms.BooleanField(
|
||||||
|
required=True,
|
||||||
|
label="Verbose Name"),
|
||||||
|
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,
|
||||||
|
'<input checked="checked" class="test" '
|
||||||
|
'id="name__1" name="name__1" type="checkbox" '
|
||||||
|
'value="custom object_1" />',
|
||||||
|
count=1, html=True)
|
||||||
|
self.assertContains(resp,
|
||||||
|
'<label class="inline-edit-label" for="name__1">'
|
||||||
|
'Verbose Name</label>',
|
||||||
|
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,
|
||||||
|
'<textarea class="test" cols="40" id="name__1" '
|
||||||
|
'name="name__1" rows="10">\r\ncustom object_1'
|
||||||
|
'</textarea>',
|
||||||
|
count=1, html=True)
|
||||||
|
|
||||||
def test_table_actions(self):
|
def test_table_actions(self):
|
||||||
# Single object action
|
# Single object action
|
||||||
action_string = "my_table__delete__1"
|
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[0].verbose_name), "Delete Me")
|
||||||
self.assertEqual(unicode(row_actions[1].verbose_name), "Log In")
|
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, '<td', 1)
|
||||||
|
self.assertContains(resp, 'inline_edit_available', 1)
|
||||||
|
self.assertContains(
|
||||||
|
resp,
|
||||||
|
'data-update-url="/my_url/?action=cell_update&'
|
||||||
|
'table=my_table&cell_name=name&obj_id=1"',
|
||||||
|
1)
|
||||||
|
self.assertContains(resp, 'table_cell_wrapper', 1)
|
||||||
|
self.assertContains(resp, 'table_cell_data_wrapper', 1)
|
||||||
|
self.assertContains(resp, 'table_cell_action', 1)
|
||||||
|
self.assertContains(resp, 'ajax-inline-edit', 1)
|
||||||
|
|
||||||
|
def test_inline_edit_update_action_get_not_allowed(self):
|
||||||
|
# Name column has required validation, sending blank
|
||||||
|
# will return error.
|
||||||
|
url = ('/my_url/?action=cell_update'
|
||||||
|
'&table=my_table&cell_name=name&obj_id=1')
|
||||||
|
req = self.factory.post(url, {})
|
||||||
|
self.table = MyTableNotAllowedInlineEdit(req, TEST_DATA_2)
|
||||||
|
handled = self.table.maybe_preempt()
|
||||||
|
# Checking the response header.
|
||||||
|
self.assertEqual(handled.status_code, 401)
|
||||||
|
|
||||||
|
def test_inline_edit_update_action_get_inline_edit_mod(self):
|
||||||
|
# Get request in inline_edit_mode should return td with form field.
|
||||||
|
url = ('/my_url/?inline_edit_mod=true&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,
|
||||||
|
'<input class="test" id="name__1" name="name__1"'
|
||||||
|
' type="text" value="custom object_1" />',
|
||||||
|
count=1, html=True)
|
||||||
|
|
||||||
|
self.assertContains(resp, '<td', 1)
|
||||||
|
self.assertContains(resp, 'inline_edit_available', 1)
|
||||||
|
self.assertContains(
|
||||||
|
resp,
|
||||||
|
'data-update-url="/my_url/?action=cell_update&'
|
||||||
|
'table=my_table&cell_name=name&obj_id=1"',
|
||||||
|
1)
|
||||||
|
self.assertContains(resp, 'table_cell_wrapper', 1)
|
||||||
|
self.assertContains(resp, 'inline-edit-error', 1)
|
||||||
|
self.assertContains(resp, 'inline-edit-form', 1)
|
||||||
|
self.assertContains(resp, 'inline-edit-actions', 1)
|
||||||
|
self.assertContains(resp, '<button', 2)
|
||||||
|
self.assertContains(resp, 'inline-edit-submit', 1)
|
||||||
|
self.assertContains(resp, 'inline-edit-cancel', 1)
|
||||||
|
|
||||||
|
def test_inline_edit_update_action_post(self):
|
||||||
|
# Post request should invoke the cell update table action.
|
||||||
|
url = ('/my_url/?action=cell_update'
|
||||||
|
'&table=my_table&cell_name=name&obj_id=1')
|
||||||
|
req = self.factory.post(url, {'name__1': 'test_name'})
|
||||||
|
self.table = MyTable(req, TEST_DATA_2)
|
||||||
|
# checking the response header
|
||||||
|
handled = self.table.maybe_preempt()
|
||||||
|
self.assertEqual(handled.status_code, 200)
|
||||||
|
|
||||||
|
def test_inline_edit_update_action_post_not_allowed(self):
|
||||||
|
# Post request should invoke the cell update table action.
|
||||||
|
url = ('/my_url/?action=cell_update'
|
||||||
|
'&table=my_table&cell_name=name&obj_id=1')
|
||||||
|
req = self.factory.post(url, {'name__1': 'test_name'})
|
||||||
|
self.table = MyTableNotAllowedInlineEdit(req, TEST_DATA_2)
|
||||||
|
# checking the response header
|
||||||
|
handled = self.table.maybe_preempt()
|
||||||
|
self.assertEqual(handled.status_code, 401)
|
||||||
|
|
||||||
|
def test_inline_edit_update_action_post_validation_error(self):
|
||||||
|
# Name column has required validation, sending blank
|
||||||
|
# will return error.
|
||||||
|
url = ('/my_url/?action=cell_update'
|
||||||
|
'&table=my_table&cell_name=name&obj_id=1')
|
||||||
|
req = self.factory.post(url, {})
|
||||||
|
self.table = MyTable(req, TEST_DATA_2)
|
||||||
|
handled = self.table.maybe_preempt()
|
||||||
|
# Checking the response header.
|
||||||
|
self.assertEqual(handled.status_code, 400)
|
||||||
|
self.assertEqual(handled._headers['content-type'],
|
||||||
|
('Content-Type', 'application/json'))
|
||||||
|
# Checking the response content.
|
||||||
|
resp = handled
|
||||||
|
self.assertContains(resp,
|
||||||
|
'"message": "This field is required."',
|
||||||
|
count=1, status_code=400)
|
||||||
|
|
||||||
def test_column_uniqueness(self):
|
def test_column_uniqueness(self):
|
||||||
table1 = MyTable(self.request)
|
table1 = MyTable(self.request)
|
||||||
table2 = MyTable(self.request)
|
table2 = MyTable(self.request)
|
||||||
|
@ -10,11 +10,15 @@
|
|||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError # noqa
|
||||||
from django.core.urlresolvers import reverse # noqa
|
from django.core.urlresolvers import reverse # noqa
|
||||||
from django.utils.http import urlencode # noqa
|
from django.utils.http import urlencode # noqa
|
||||||
from django.utils.translation import ugettext_lazy as _ # noqa
|
from django.utils.translation import ugettext_lazy as _ # noqa
|
||||||
|
|
||||||
|
from horizon import exceptions
|
||||||
|
from horizon import forms
|
||||||
from horizon import tables
|
from horizon import tables
|
||||||
|
from keystoneclient.exceptions import Conflict # noqa
|
||||||
|
|
||||||
from openstack_dashboard import api
|
from openstack_dashboard import api
|
||||||
from openstack_dashboard.api import keystone
|
from openstack_dashboard.api import keystone
|
||||||
@ -121,16 +125,65 @@ class TenantFilterAction(tables.FilterAction):
|
|||||||
return filter(comp, tenants)
|
return filter(comp, tenants)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateRow(tables.Row):
|
||||||
|
ajax = True
|
||||||
|
|
||||||
|
def get_data(self, request, project_id):
|
||||||
|
project_info = api.keystone.tenant_get(request, project_id,
|
||||||
|
admin=True)
|
||||||
|
return project_info
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateCell(tables.UpdateAction):
|
||||||
|
def allowed(self, request, project, cell):
|
||||||
|
return api.keystone.keystone_can_edit_project()
|
||||||
|
|
||||||
|
def update_cell(self, request, datum, project_id,
|
||||||
|
cell_name, new_cell_value):
|
||||||
|
# inline update project info
|
||||||
|
try:
|
||||||
|
project_obj = datum
|
||||||
|
# updating changed value by new value
|
||||||
|
setattr(project_obj, cell_name, new_cell_value)
|
||||||
|
api.keystone.tenant_update(
|
||||||
|
request,
|
||||||
|
project_id,
|
||||||
|
name=project_obj.name,
|
||||||
|
description=project_obj.description,
|
||||||
|
enabled=project_obj.enabled)
|
||||||
|
|
||||||
|
except Conflict:
|
||||||
|
# Returning a nice error message about name conflict. The message
|
||||||
|
# from exception is not that clear for the users.
|
||||||
|
message = _("This name is already taken.")
|
||||||
|
raise ValidationError(message)
|
||||||
|
except Exception:
|
||||||
|
exceptions.handle(request, ignore=True)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class TenantsTable(tables.DataTable):
|
class TenantsTable(tables.DataTable):
|
||||||
name = tables.Column('name', verbose_name=_('Name'))
|
name = tables.Column('name', verbose_name=_('Name'),
|
||||||
|
form_field=forms.CharField(required=True),
|
||||||
|
update_action=UpdateCell)
|
||||||
description = tables.Column(lambda obj: getattr(obj, 'description', None),
|
description = tables.Column(lambda obj: getattr(obj, 'description', None),
|
||||||
verbose_name=_('Description'))
|
verbose_name=_('Description'),
|
||||||
|
form_field=forms.CharField(
|
||||||
|
widget=forms.Textarea(),
|
||||||
|
required=False),
|
||||||
|
update_action=UpdateCell)
|
||||||
id = tables.Column('id', verbose_name=_('Project ID'))
|
id = tables.Column('id', verbose_name=_('Project ID'))
|
||||||
enabled = tables.Column('enabled', verbose_name=_('Enabled'), status=True)
|
enabled = tables.Column('enabled', verbose_name=_('Enabled'), status=True,
|
||||||
|
form_field=forms.BooleanField(
|
||||||
|
label=_('Enabled'),
|
||||||
|
required=False),
|
||||||
|
update_action=UpdateCell)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
name = "tenants"
|
name = "tenants"
|
||||||
verbose_name = _("Projects")
|
verbose_name = _("Projects")
|
||||||
|
row_class = UpdateRow
|
||||||
row_actions = (ViewMembersLink, ViewGroupsLink, UpdateProject,
|
row_actions = (ViewMembersLink, ViewGroupsLink, UpdateProject,
|
||||||
UsageLink, ModifyQuotas, DeleteTenantsAction)
|
UsageLink, ModifyQuotas, DeleteTenantsAction)
|
||||||
table_actions = (TenantFilterAction, CreateProject,
|
table_actions = (TenantFilterAction, CreateProject,
|
||||||
|
@ -14,22 +14,26 @@
|
|||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
|
import copy
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.core.urlresolvers import reverse # noqa
|
from django.core.urlresolvers import reverse # noqa
|
||||||
from django import http
|
from django import http
|
||||||
from django.test.utils import override_settings # noqa
|
from django.test.utils import override_settings # noqa
|
||||||
|
|
||||||
|
from mox import IgnoreArg # noqa
|
||||||
from mox import IsA # noqa
|
from mox import IsA # noqa
|
||||||
|
|
||||||
from horizon import exceptions
|
from horizon import exceptions
|
||||||
from horizon.workflows import views
|
from horizon.workflows import views
|
||||||
|
|
||||||
from openstack_dashboard import api
|
from openstack_dashboard import api
|
||||||
|
from openstack_dashboard.dashboards.admin.projects import workflows
|
||||||
from openstack_dashboard.test import helpers as test
|
from openstack_dashboard.test import helpers as test
|
||||||
from openstack_dashboard.usage import quotas
|
from openstack_dashboard.usage import quotas
|
||||||
|
|
||||||
from openstack_dashboard.dashboards.admin.projects import workflows
|
from selenium.webdriver import ActionChains # noqa
|
||||||
|
from socket import timeout as socket_timeout # noqa
|
||||||
|
|
||||||
|
|
||||||
INDEX_URL = reverse('horizon:admin:projects:index')
|
INDEX_URL = reverse('horizon:admin:projects:index')
|
||||||
@ -1440,3 +1444,137 @@ class UpdateProjectWorkflowTests(test.BaseAdminViewTests):
|
|||||||
self.client.get(url)
|
self.client.get(url)
|
||||||
finally:
|
finally:
|
||||||
logging.disable(logging.NOTSET)
|
logging.disable(logging.NOTSET)
|
||||||
|
|
||||||
|
|
||||||
|
class SeleniumTests(test.SeleniumAdminTestCase):
|
||||||
|
@test.create_stubs(
|
||||||
|
{api.keystone: ('tenant_list', 'tenant_get', 'tenant_update')})
|
||||||
|
def test_inline_editing_update(self):
|
||||||
|
# Tenant List
|
||||||
|
api.keystone.tenant_list(IgnoreArg(),
|
||||||
|
domain=None,
|
||||||
|
marker=None,
|
||||||
|
paginate=True) \
|
||||||
|
.AndReturn([self.tenants.list(), False])
|
||||||
|
# Edit mod
|
||||||
|
api.keystone.tenant_get(IgnoreArg(),
|
||||||
|
u'1',
|
||||||
|
admin=True) \
|
||||||
|
.AndReturn(self.tenants.list()[0])
|
||||||
|
# Update - requires get and update
|
||||||
|
api.keystone.tenant_get(IgnoreArg(),
|
||||||
|
u'1',
|
||||||
|
admin=True) \
|
||||||
|
.AndReturn(self.tenants.list()[0])
|
||||||
|
api.keystone.tenant_update(
|
||||||
|
IgnoreArg(),
|
||||||
|
u'1',
|
||||||
|
description='a test tenant.',
|
||||||
|
enabled=True,
|
||||||
|
name=u'Changed test_tenant')
|
||||||
|
# Refreshing cell with changed name
|
||||||
|
changed_tenant = copy.copy(self.tenants.list()[0])
|
||||||
|
changed_tenant.name = u'Changed test_tenant'
|
||||||
|
api.keystone.tenant_get(IgnoreArg(),
|
||||||
|
u'1',
|
||||||
|
admin=True) \
|
||||||
|
.AndReturn(changed_tenant)
|
||||||
|
|
||||||
|
self.mox.ReplayAll()
|
||||||
|
|
||||||
|
self.selenium.get("%s%s" % (self.live_server_url, INDEX_URL))
|
||||||
|
|
||||||
|
# Check the presence of the important elements
|
||||||
|
td_element = self.selenium.find_element_by_xpath(
|
||||||
|
"//td[@data-update-url='/admin/projects/?action=cell_update"
|
||||||
|
"&table=tenants&cell_name=name&obj_id=1']")
|
||||||
|
cell_wrapper = td_element.find_element_by_class_name(
|
||||||
|
'table_cell_wrapper')
|
||||||
|
edit_button_wrapper = td_element.find_element_by_class_name(
|
||||||
|
'table_cell_action')
|
||||||
|
edit_button = edit_button_wrapper.find_element_by_tag_name('button')
|
||||||
|
# Hovering over td and clicking on edit button
|
||||||
|
action_chains = ActionChains(self.selenium)
|
||||||
|
action_chains.move_to_element(cell_wrapper).click(edit_button)
|
||||||
|
action_chains.perform()
|
||||||
|
# Waiting for the AJAX response for switching to editing mod
|
||||||
|
wait = self.ui.WebDriverWait(self.selenium, 10,
|
||||||
|
ignored_exceptions=[socket_timeout])
|
||||||
|
wait.until(lambda x: self.selenium.find_element_by_name("name__1"))
|
||||||
|
# Changing project name in cell form
|
||||||
|
td_element = self.selenium.find_element_by_xpath(
|
||||||
|
"//td[@data-update-url='/admin/projects/?action=cell_update"
|
||||||
|
"&table=tenants&cell_name=name&obj_id=1']")
|
||||||
|
td_element.find_element_by_tag_name('input').send_keys("Changed ")
|
||||||
|
# Saving new project name by AJAX
|
||||||
|
td_element.find_element_by_class_name('inline-edit-submit').click()
|
||||||
|
# Waiting for the AJAX response of cell refresh
|
||||||
|
wait = self.ui.WebDriverWait(self.selenium, 10,
|
||||||
|
ignored_exceptions=[socket_timeout])
|
||||||
|
wait.until(lambda x: self.selenium.find_element_by_xpath(
|
||||||
|
"//td[@data-update-url='/admin/projects/?action=cell_update"
|
||||||
|
"&table=tenants&cell_name=name&obj_id=1']"
|
||||||
|
"/div[@class='table_cell_wrapper']"
|
||||||
|
"/div[@class='table_cell_data_wrapper']"))
|
||||||
|
# Checking new project name after cell refresh
|
||||||
|
data_wrapper = self.selenium.find_element_by_xpath(
|
||||||
|
"//td[@data-update-url='/admin/projects/?action=cell_update"
|
||||||
|
"&table=tenants&cell_name=name&obj_id=1']"
|
||||||
|
"/div[@class='table_cell_wrapper']"
|
||||||
|
"/div[@class='table_cell_data_wrapper']")
|
||||||
|
self.assertTrue(data_wrapper.text == u'Changed test_tenant',
|
||||||
|
"Error: saved tenant name is expected to be "
|
||||||
|
"'Changed test_tenant'")
|
||||||
|
|
||||||
|
@test.create_stubs(
|
||||||
|
{api.keystone: ('tenant_list', 'tenant_get')})
|
||||||
|
def test_inline_editing_cancel(self):
|
||||||
|
# Tenant List
|
||||||
|
api.keystone.tenant_list(IgnoreArg(),
|
||||||
|
domain=None,
|
||||||
|
marker=None,
|
||||||
|
paginate=True) \
|
||||||
|
.AndReturn([self.tenants.list(), False])
|
||||||
|
# Edit mod
|
||||||
|
api.keystone.tenant_get(IgnoreArg(),
|
||||||
|
u'1',
|
||||||
|
admin=True) \
|
||||||
|
.AndReturn(self.tenants.list()[0])
|
||||||
|
# Cancel edit mod is without the request
|
||||||
|
|
||||||
|
self.mox.ReplayAll()
|
||||||
|
|
||||||
|
self.selenium.get("%s%s" % (self.live_server_url, INDEX_URL))
|
||||||
|
|
||||||
|
# Check the presence of the important elements
|
||||||
|
td_element = self.selenium.find_element_by_xpath(
|
||||||
|
"//td[@data-update-url='/admin/projects/?action=cell_update"
|
||||||
|
"&table=tenants&cell_name=name&obj_id=1']")
|
||||||
|
cell_wrapper = td_element.find_element_by_class_name(
|
||||||
|
'table_cell_wrapper')
|
||||||
|
edit_button_wrapper = td_element.find_element_by_class_name(
|
||||||
|
'table_cell_action')
|
||||||
|
edit_button = edit_button_wrapper.find_element_by_tag_name('button')
|
||||||
|
# Hovering over td and clicking on edit
|
||||||
|
action_chains = ActionChains(self.selenium)
|
||||||
|
action_chains.move_to_element(cell_wrapper).click(edit_button)
|
||||||
|
action_chains.perform()
|
||||||
|
# Waiting for the AJAX response for switching to editing mod
|
||||||
|
wait = self.ui.WebDriverWait(self.selenium, 10,
|
||||||
|
ignored_exceptions=[socket_timeout])
|
||||||
|
wait.until(lambda x: self.selenium.find_element_by_name("name__1"))
|
||||||
|
# Click on cancel button
|
||||||
|
td_element = self.selenium.find_element_by_xpath(
|
||||||
|
"//td[@data-update-url='/admin/projects/?action=cell_update"
|
||||||
|
"&table=tenants&cell_name=name&obj_id=1']")
|
||||||
|
td_element.find_element_by_class_name('inline-edit-cancel').click()
|
||||||
|
# Cancel is via javascript, so it should be immediate
|
||||||
|
# Checking that tenant name is not changed
|
||||||
|
data_wrapper = self.selenium.find_element_by_xpath(
|
||||||
|
"//td[@data-update-url='/admin/projects/?action=cell_update"
|
||||||
|
"&table=tenants&cell_name=name&obj_id=1']"
|
||||||
|
"/div[@class='table_cell_wrapper']"
|
||||||
|
"/div[@class='table_cell_data_wrapper']")
|
||||||
|
self.assertTrue(data_wrapper.text == u'test_tenant',
|
||||||
|
"Error: saved tenant name is expected to be "
|
||||||
|
"'test_tenant'")
|
||||||
|
BIN
openstack_dashboard/static/dashboard/img/spinner.gif
Normal file
BIN
openstack_dashboard/static/dashboard/img/spinner.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.6 KiB |
@ -607,7 +607,202 @@ table form {
|
|||||||
button.btn-delete, button.btn-terminate {
|
button.btn-delete, button.btn-terminate {
|
||||||
.btn-icon-danger(-451px, 5px);
|
.btn-icon-danger(-451px, 5px);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
td.no-transition {
|
||||||
|
-webkit-transition: none !important;
|
||||||
|
-moz-transition: none !important;
|
||||||
|
-o-transition: none !important;
|
||||||
|
-ms-transition: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.success {
|
||||||
|
background-color: #dff0d8 !important;
|
||||||
|
}
|
||||||
|
td.loading {
|
||||||
|
background-color: #e6e6e6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-inline_edit(@x, @y, @top: 1px, @left: 5px, @icons: "/static/bootstrap/img/glyphicons-halflings.png") {
|
||||||
|
padding: 9px 12px 9px 12px;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 0px;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
display: inline-block;
|
||||||
|
content: "";
|
||||||
|
width: 18px;
|
||||||
|
height: 20px;
|
||||||
|
margin-top: 0px;
|
||||||
|
*margin-right: .3em;
|
||||||
|
line-height: 14px;
|
||||||
|
background-image: url(@icons);
|
||||||
|
background-position: @x @y;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
position: absolute;
|
||||||
|
top: @top;
|
||||||
|
left: @left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
td.inline_edit_available {
|
||||||
|
div.table_cell_wrapper {
|
||||||
|
.table_cell_action {
|
||||||
|
button.ajax-inline-edit {
|
||||||
|
.btn-icon-inline_edit(0, -72px, 2px, 4px);
|
||||||
|
|
||||||
|
padding: 10px 10px 10px 10px;
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
background: none;
|
||||||
|
border: 0 none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-inline-actions(@x, @y, @top: 1px, @left: 5px, @icons: "/static/bootstrap/img/glyphicons-halflings.png") {
|
||||||
|
padding: 9px 12px 9px 12px;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 0px;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
display: inline-block;
|
||||||
|
content: "";
|
||||||
|
width: 18px;
|
||||||
|
height: 20px;
|
||||||
|
margin-top: 0px;
|
||||||
|
*margin-right: .3em;
|
||||||
|
line-height: 14px;
|
||||||
|
background-image: url(@icons);
|
||||||
|
background-position: @x @y;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
position: absolute;
|
||||||
|
top: @top;
|
||||||
|
left: @left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon(@x, @y, @top: 1px, @left: 5px, @icons: "/static/bootstrap/img/glyphicons-halflings.png") {
|
||||||
|
padding: 9px 12px 9px 12px;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 0px;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
display: inline-block;
|
||||||
|
content: "";
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-top: 0px;
|
||||||
|
*margin-right: .3em;
|
||||||
|
line-height: 14px;
|
||||||
|
background-image: url(@icons);
|
||||||
|
background-position: @x @y;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
position: absolute;
|
||||||
|
top: @top;
|
||||||
|
left: @left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div.table_cell_wrapper {
|
||||||
|
min-height: 18px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.inline-edit-label {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-form {
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-actions, .table_cell_action {
|
||||||
|
float: right;
|
||||||
|
width: 20px;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
button.inline-edit-cancel {
|
||||||
|
float: right;
|
||||||
|
.btn-icon-inline-actions(-312px, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.inline-edit-submit {
|
||||||
|
.btn-icon-inline-actions(-288px, 0, 1px, 5px, "/static/bootstrap/img/glyphicons-halflings-white.png");
|
||||||
|
}
|
||||||
|
button.ajax-inline-edit {
|
||||||
|
.btn-icon-inline-actions(0, -72px, 2px, 4px);
|
||||||
|
|
||||||
|
padding: 10px 10px 10px 10px;
|
||||||
|
position: relative;
|
||||||
|
display: none;
|
||||||
|
background: none;
|
||||||
|
border: 0 none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table_cell_action {
|
||||||
|
width: auto;
|
||||||
|
|
||||||
|
margin: auto 0px 0px 0px;
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
right: 0px;
|
||||||
|
z-index: 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table_cell_action.hovered {
|
||||||
|
background-image: -webkit-linear-gradient(top, #ffffff, #e6e6e6);
|
||||||
|
border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-bottom-color: #bbb;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-error {
|
||||||
|
.error {
|
||||||
|
.status-icon(-144px, -120px, 0px, 0px);
|
||||||
|
position: absolute;
|
||||||
|
width: 18px;
|
||||||
|
height: 20px;
|
||||||
|
|
||||||
|
top: 20px;
|
||||||
|
left: 2px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.inline-edit-status {
|
||||||
|
.success {
|
||||||
|
.status-icon(-288px, 0px, 0px, 0px);
|
||||||
|
padding: 0;
|
||||||
|
position:absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 18px;
|
||||||
|
|
||||||
|
width: 18px;
|
||||||
|
height: 20px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.loading {
|
||||||
|
.status-icon(0px, 0px, 0px, 0px, '/static/dashboard/img/spinner.gif');
|
||||||
|
padding: 0;
|
||||||
|
position:absolute;
|
||||||
|
top: 0px;
|
||||||
|
right: 24px;
|
||||||
|
|
||||||
|
width: 18px;
|
||||||
|
height: 20px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.inline-edit-status.inline-edit-mod {
|
||||||
|
.loading {
|
||||||
|
top: 15px;
|
||||||
|
right: 34px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.table_header .table_actions {
|
.table_header .table_actions {
|
||||||
|
Loading…
Reference in New Issue
Block a user