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 .. autoclass:: DeleteAction
:members: :members:
.. autoclass:: UpdateAction
:members:
Class-Based Views Class-Based Views
================= =================

View File

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

View File

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

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

View File

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

View File

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

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

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

View File

@ -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&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): 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&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): def test_column_uniqueness(self):
table1 = MyTable(self.request) table1 = MyTable(self.request)
table2 = MyTable(self.request) table2 = MyTable(self.request)

View File

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

View File

@ -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'")

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