Merge "Inline Table editing"

This commit is contained in:
Jenkins 2013-12-04 01:13:18 +00:00 committed by Gerrit Code Review
commit efc88d4078
15 changed files with 1415 additions and 21 deletions

View File

@ -76,6 +76,9 @@ Actions
.. autoclass:: DeleteAction
:members:
.. autoclass:: UpdateAction
:members:
Class-Based Views
=================

View File

@ -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

View File

@ -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('<input type="checkbox">');
}
});
@ -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 {

View 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");
});
});

View File

@ -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

View File

@ -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

View File

@ -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 ``<ul></ul>`` 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.

View File

@ -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.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_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.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>

View 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 %}

View File

@ -1,3 +1,7 @@
<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>

View File

@ -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):
['<Column: multi_select>',
'<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):
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, '<td', 1)
self.assertContains(resp, 'inline_edit_available', 1)
self.assertContains(resp,
'data-update-url="?action=cell_update&amp;'
'table=my_table&amp;cell_name=name&amp;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&amp;'
'table=my_table&amp;cell_name=name&amp;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&amp;'
'table=my_table&amp;cell_name=name&amp;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):
# 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, '<td', 1)
self.assertContains(resp, 'inline_edit_available', 1)
self.assertContains(
resp,
'data-update-url="/my_url/?action=cell_update&amp;'
'table=my_table&amp;cell_name=name&amp;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&amp;'
'table=my_table&amp;cell_name=name&amp;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):
table1 = MyTable(self.request)
table2 = MyTable(self.request)

View File

@ -10,11 +10,15 @@
# License for the specific language governing permissions and limitations
# under the License.
from django.core.exceptions import ValidationError # noqa
from django.core.urlresolvers import reverse # noqa
from django.utils.http import urlencode # noqa
from django.utils.translation import ugettext_lazy as _ # noqa
from horizon import exceptions
from horizon import forms
from horizon import tables
from keystoneclient.exceptions import Conflict # noqa
from openstack_dashboard import api
from openstack_dashboard.api import keystone
@ -121,16 +125,65 @@ class TenantFilterAction(tables.FilterAction):
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):
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),
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'))
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:
name = "tenants"
verbose_name = _("Projects")
row_class = UpdateRow
row_actions = (ViewMembersLink, ViewGroupsLink, UpdateProject,
UsageLink, ModifyQuotas, DeleteTenantsAction)
table_actions = (TenantFilterAction, CreateProject,

View File

@ -14,22 +14,26 @@
# License for the specific language governing permissions and limitations
# under the License.
import copy
import logging
from django.core.urlresolvers import reverse # noqa
from django import http
from django.test.utils import override_settings # noqa
from mox import IgnoreArg # noqa
from mox import IsA # noqa
from horizon import exceptions
from horizon.workflows import views
from openstack_dashboard import api
from openstack_dashboard.dashboards.admin.projects import workflows
from openstack_dashboard.test import helpers as test
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')
@ -1440,3 +1444,137 @@ class UpdateProjectWorkflowTests(test.BaseAdminViewTests):
self.client.get(url)
finally:
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'")

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -607,7 +607,202 @@ table form {
button.btn-delete, button.btn-terminate {
.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 {