Inline Table editing
Implementation of inline-table-editing blueprint: - Design from jcoufal is implemented with Bootstrap elements. - Inline edit mode implemented, Django Form Field connected to table logic, using Django Form Widget to render. Table Cell can be updated and refreshed. - Allowed method implemented for checking of cell edit permissions - Validation process is taken from Django Form Field logic, so validation is defined as part of Django Form Field defined in table Column. - Update method of the cell is defined as Table Action. For obtaining row data, UpdateRow functionality is used. - Horizon Tests for inline editing implemented. - Selenium Tests for inline editing of tenants implemented - Documentation written in both ref and topics Change-Id: Ib29b58da71d3a8abc9688bc942fe49917161e97a Implements: blueprint inline-table-editing
This commit is contained in:
parent
7508917199
commit
c77d9074f1
@ -76,6 +76,9 @@ Actions
|
||||
.. autoclass:: DeleteAction
|
||||
:members:
|
||||
|
||||
.. autoclass:: UpdateAction
|
||||
:members:
|
||||
|
||||
Class-Based Views
|
||||
=================
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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 {
|
||||
|
271
horizon/static/horizon/js/horizon.tables_inline_edit.js
Normal file
271
horizon/static/horizon/js/horizon.tables_inline_edit.js
Normal file
@ -0,0 +1,271 @@
|
||||
horizon.inline_edit = {
|
||||
get_cell_id: function (td_element) {
|
||||
return (td_element.parents("tr").first().data("object-id")
|
||||
+ "__" + td_element.data("cell-name"));
|
||||
},
|
||||
get_object_container: function (td_element) {
|
||||
// global cell object container
|
||||
if (!window.cell_object_container) {
|
||||
window.cell_object_container = new Array();
|
||||
}
|
||||
return window.cell_object_container;
|
||||
},
|
||||
get_cell_object: function (td_element) {
|
||||
var cell_id = horizon.inline_edit.get_cell_id(td_element);
|
||||
var id = "cell__" + cell_id;
|
||||
var container = horizon.inline_edit.get_object_container(td_element);
|
||||
if (container && container[id]){
|
||||
// if cell object exists, I will reuse it
|
||||
var cell_object = container[id];
|
||||
cell_object.reset_with(td_element);
|
||||
return cell_object;
|
||||
} else {
|
||||
// or I will create new cell object
|
||||
var cell_object = new horizon.inline_edit.Cell(td_element);
|
||||
// saving cell object to global container
|
||||
container[id] = cell_object;
|
||||
return cell_object;
|
||||
}
|
||||
},
|
||||
Cell: function (td_element){
|
||||
var self = this;
|
||||
|
||||
// setting initial attributes
|
||||
self.reset_with = function(td_element){
|
||||
self.td_element = td_element;
|
||||
self.form_element = td_element.find("input, textarea");
|
||||
self.url = td_element.data('update-url');
|
||||
self.inline_edit_mod = false;
|
||||
self.successful_update = false;
|
||||
};
|
||||
self.reset_with(td_element);
|
||||
|
||||
self.refresh = function () {
|
||||
horizon.ajax.queue({
|
||||
url: self.url,
|
||||
data: {'inline_edit_mod': self.inline_edit_mod},
|
||||
beforeSend: function () {
|
||||
self.start_loading();
|
||||
},
|
||||
complete: function () {
|
||||
// Bug in Jquery tool-tip, if I hover tool-tip, then confirm the field with
|
||||
// enter and the cell is reloaded, tool-tip stays. So just to be sure, I am
|
||||
// removing tool-tips manually
|
||||
$(".tooltip.fade.top.in").remove();
|
||||
self.stop_loading();
|
||||
|
||||
if (self.successful_update) {
|
||||
// if cell was updated successfully, I will show fading check sign
|
||||
var success = $('<div class="success"></div>');
|
||||
self.td_element.find('.inline-edit-status').append(success);
|
||||
|
||||
var background_color = self.td_element.css('background-color');
|
||||
|
||||
// edit pencil will disappear and appear again once the check sign has faded
|
||||
// also green background will disappear
|
||||
self.td_element.addClass("no-transition");
|
||||
self.td_element.addClass("success");
|
||||
self.td_element.removeClass("no-transition");
|
||||
|
||||
self.td_element.removeClass("inline_edit_available");
|
||||
|
||||
success.fadeOut(1300, function () {
|
||||
self.td_element.addClass("inline_edit_available");
|
||||
self.td_element.removeClass("success");
|
||||
});
|
||||
}
|
||||
},
|
||||
error: function(jqXHR, status, errorThrown) {
|
||||
if (jqXHR.status === 401){
|
||||
var redir_url = jqXHR.getResponseHeader("X-Horizon-Location");
|
||||
if (redir_url){
|
||||
location.href = redir_url;
|
||||
} else {
|
||||
horizon.alert("error", gettext("Not authorized to do this operation."));
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (!horizon.ajax.get_messages(jqXHR)) {
|
||||
// Generic error handler. Really generic.
|
||||
horizon.alert("error", gettext("An error occurred. Please try again later."));
|
||||
}
|
||||
}
|
||||
},
|
||||
success: function (data, textStatus, jqXHR) {
|
||||
var td_element = $(data);
|
||||
self.form_element = self.get_form_element(td_element);
|
||||
|
||||
if (self.inline_edit_mod) {
|
||||
// if cell is in inline edit mode
|
||||
var table_cell_wrapper = td_element.find(".table_cell_wrapper");
|
||||
|
||||
width = self.td_element.outerWidth();
|
||||
height = self.td_element.outerHeight();
|
||||
|
||||
td_element.width(width);
|
||||
td_element.height(height);
|
||||
td_element.css('margin', 0).css('padding', 0);
|
||||
table_cell_wrapper.css('margin', 0).css('padding', 0);
|
||||
|
||||
if (self.form_element.attr('type')=='checkbox'){
|
||||
var inline_edit_form = td_element.find(".inline-edit-form");
|
||||
inline_edit_form.css('padding-top', '11px').css('padding-left', '4px');
|
||||
inline_edit_form.width(width - 40);
|
||||
} else {
|
||||
// setting CSS of element, so the cell remains the same size in editing mode
|
||||
self.form_element.width(width - 40);
|
||||
self.form_element.height(height - 2);
|
||||
self.form_element.css('margin', 0).css('padding', 0);
|
||||
}
|
||||
}
|
||||
// saving old td_element for cancel and loading purposes
|
||||
self.cached_presentation_view = self.td_element;
|
||||
// replacing old td with the new td element returned from the server
|
||||
self.rewrite_cell(td_element);
|
||||
// focusing the form element inside the cell
|
||||
if (self.inline_edit_mod) {
|
||||
self.form_element.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
self.update = function(post_data){
|
||||
// make the update request
|
||||
horizon.ajax.queue({
|
||||
type: 'POST',
|
||||
url: self.url,
|
||||
data: post_data,
|
||||
beforeSend: function () {
|
||||
self.start_loading();
|
||||
},
|
||||
complete: function () {
|
||||
if (!self.successful_update){
|
||||
self.stop_loading();
|
||||
}
|
||||
},
|
||||
error: function(jqXHR, status, errorThrown) {
|
||||
if (jqXHR.status === 400){
|
||||
// make place for error icon, only if the error icon is not already present
|
||||
if (self.td_element.find(".inline-edit-error .error").length <= 0) {
|
||||
self.form_element.css('padding-left', '20px');
|
||||
self.form_element.width(self.form_element.width() - 20);
|
||||
}
|
||||
// obtain the error message from response body
|
||||
error_message = $.parseJSON(jqXHR.responseText).message;
|
||||
// insert the error icon
|
||||
var error = $('<div title="' + error_message + '" class="error"></div>')
|
||||
self.td_element.find(".inline-edit-error").html(error);
|
||||
error.tooltip({'placement':'top'});
|
||||
}
|
||||
else if (jqXHR.status === 401){
|
||||
var redir_url = jqXHR.getResponseHeader("X-Horizon-Location");
|
||||
if (redir_url){
|
||||
location.href = redir_url;
|
||||
} else {
|
||||
horizon.alert("error", gettext("Not authorized to do this operation."));
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (!horizon.ajax.get_messages(jqXHR)) {
|
||||
// Generic error handler. Really generic.
|
||||
horizon.alert("error", gettext("An error occurred. Please try again later."));
|
||||
}
|
||||
}
|
||||
},
|
||||
success: function (data, textStatus, jqXHR) {
|
||||
// if update was successful
|
||||
self.successful_update = true;
|
||||
self.refresh();
|
||||
}
|
||||
});
|
||||
};
|
||||
self.cancel = function() {
|
||||
self.rewrite_cell(self.cached_presentation_view);
|
||||
self.stop_loading();
|
||||
};
|
||||
self.get_form_element = function(td_element){
|
||||
return td_element.find("input, textarea");
|
||||
};
|
||||
self.rewrite_cell = function(td_element){
|
||||
self.td_element.replaceWith(td_element);
|
||||
self.td_element = td_element;
|
||||
};
|
||||
self.start_loading = function() {
|
||||
self.td_element.addClass("no-transition");
|
||||
|
||||
var spinner = $('<div class="loading"></div>');
|
||||
self.td_element.find('.inline-edit-status').append(spinner);
|
||||
self.td_element.addClass("loading");
|
||||
self.td_element.removeClass("inline_edit_available");
|
||||
self.get_form_element(self.td_element).attr("disabled", "disabled");
|
||||
};
|
||||
self.stop_loading = function() {
|
||||
self.td_element.find('div.inline-edit-status div.loading').remove();
|
||||
self.td_element.removeClass("loading");
|
||||
self.td_element.addClass("inline_edit_available");
|
||||
self.get_form_element(self.td_element).removeAttr("disabled");
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
horizon.addInitFunction(function() {
|
||||
$('table').on('click', '.ajax-inline-edit', function (evt) {
|
||||
var $this = $(this);
|
||||
var td_element = $this.parents('td').first();
|
||||
|
||||
var cell = horizon.inline_edit.get_cell_object(td_element);
|
||||
cell.inline_edit_mod = true;
|
||||
cell.refresh();
|
||||
|
||||
evt.preventDefault();
|
||||
});
|
||||
|
||||
var submit_form = function(evt, el){
|
||||
var $submit = $(el);
|
||||
var td_element = $submit.parents('td').first();
|
||||
var post_data = $submit.parents('form').first().serialize();
|
||||
|
||||
var cell = horizon.inline_edit.get_cell_object(td_element);
|
||||
cell.update(post_data);
|
||||
|
||||
evt.preventDefault();
|
||||
}
|
||||
|
||||
$('table').on('click', '.inline-edit-submit', function (evt) {
|
||||
submit_form(evt, this);
|
||||
});
|
||||
|
||||
$('table').on('keypress', '.inline-edit-form', function (evt) {
|
||||
if (evt.which == 13 && !evt.shiftKey) {
|
||||
submit_form(evt, this);
|
||||
}
|
||||
});
|
||||
|
||||
$('table').on('click', '.inline-edit-cancel', function (evt) {
|
||||
var $cancel = $(this);
|
||||
var td_element = $cancel.parents('td').first();
|
||||
|
||||
var cell = horizon.inline_edit.get_cell_object(td_element);
|
||||
cell.cancel();
|
||||
|
||||
evt.preventDefault();
|
||||
});
|
||||
|
||||
$('table').on('mouseenter', '.inline_edit_available', function (evt) {
|
||||
$(this).find(".table_cell_action").fadeIn(100);
|
||||
});
|
||||
|
||||
$('table').on('mouseleave', '.inline_edit_available', function (evt) {
|
||||
$(this).find(".table_cell_action").fadeOut(200);
|
||||
});
|
||||
|
||||
$('table').on('mouseenter', '.table_cell_action', function (evt) {
|
||||
$(this).addClass("hovered");
|
||||
});
|
||||
|
||||
$('table').on('mouseleave', '.table_cell_action', function (evt) {
|
||||
$(this).removeClass("hovered");
|
||||
});
|
||||
});
|
||||
|
@ -21,6 +21,7 @@ from horizon.tables.actions import DeleteAction # noqa
|
||||
from horizon.tables.actions import FilterAction # noqa
|
||||
from horizon.tables.actions import 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
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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>
|
||||
|
37
horizon/templates/horizon/common/_data_table_cell.html
Normal file
37
horizon/templates/horizon/common/_data_table_cell.html
Normal file
@ -0,0 +1,37 @@
|
||||
{% if cell.inline_edit_mod and cell.update_allowed %}
|
||||
<td{{ cell.attr_string|safe }}>
|
||||
<div class="table_cell_wrapper">
|
||||
<div class="inline-edit-error"> </div>
|
||||
<div class="inline-edit-form">
|
||||
{{ cell.value }}
|
||||
{% if cell.column.form_field.label %}
|
||||
<label class="inline-edit-label" for="{{ cell.id }}">{{ cell.column.form_field.label }}</label>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="inline-edit-actions">
|
||||
<button class="inline-edit-submit btn btn-primary pull-right"
|
||||
name="action"
|
||||
value="" type="submit">
|
||||
</button>
|
||||
<button class="inline-edit-cancel btn secondary cancel"></button>
|
||||
</div>
|
||||
<div class="inline-edit-status inline-edit-mod"></div>
|
||||
</div>
|
||||
</td>
|
||||
{% else %}
|
||||
{% if cell.inline_edit_available and cell.update_allowed %}
|
||||
<td{{ cell.attr_string|safe }}>
|
||||
<div class="table_cell_wrapper">
|
||||
<div class="table_cell_data_wrapper">
|
||||
{%if cell.wrap_list %}<ul>{% endif %}{{ cell.value }}{%if cell.wrap_list %}</ul>{% endif %}
|
||||
</div>
|
||||
<div class="table_cell_action">
|
||||
<button class="ajax-inline-edit btn-edit"></button>
|
||||
</div>
|
||||
<div class="inline-edit-status"></div>
|
||||
</div>
|
||||
</td>
|
||||
{% else %}
|
||||
<td{{ cell.attr_string|safe }}>{{ cell.value }}</td>
|
||||
{% endif %}
|
||||
{% endif %}
|
@ -1,3 +1,7 @@
|
||||
<tr{{ row.attr_string|safe }}>
|
||||
{% 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>
|
||||
|
@ -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&'
|
||||
'table=my_table&cell_name=name&obj_id=1"',
|
||||
1)
|
||||
self.assertContains(resp, 'table_cell_wrapper', 1)
|
||||
self.assertContains(resp, 'table_cell_data_wrapper', 1)
|
||||
self.assertContains(resp, 'table_cell_action', 1)
|
||||
self.assertContains(resp, 'ajax-inline-edit', 1)
|
||||
|
||||
def test_inline_edit_available_not_allowed_cell_rendering(self):
|
||||
self.table = MyTableNotAllowedInlineEdit(self.request, TEST_DATA_2)
|
||||
|
||||
row = self.table.get_rows()[0]
|
||||
name_cell = row.cells['name']
|
||||
|
||||
# Check if in-line edit is available in the cell,
|
||||
# but is not in inline_edit_mod.
|
||||
self.assertEqual(name_cell.inline_edit_available,
|
||||
True)
|
||||
self.assertEqual(name_cell.inline_edit_mod,
|
||||
False)
|
||||
|
||||
# Check if is cell is rendered correctly.
|
||||
name_cell_rendered = name_cell.render()
|
||||
resp = http.HttpResponse(name_cell_rendered)
|
||||
|
||||
self.assertContains(resp, '<td', 1)
|
||||
self.assertContains(resp, 'inline_edit_available', 1)
|
||||
self.assertContains(resp,
|
||||
'data-update-url="?action=cell_update&'
|
||||
'table=my_table&cell_name=name&obj_id=1"',
|
||||
1)
|
||||
self.assertContains(resp, 'table_cell_wrapper', 0)
|
||||
self.assertContains(resp, 'table_cell_data_wrapper', 0)
|
||||
self.assertContains(resp, 'table_cell_action', 0)
|
||||
self.assertContains(resp, 'ajax-inline-edit', 0)
|
||||
|
||||
def test_inline_edit_mod_cell_rendering(self):
|
||||
self.table = MyTable(self.request, TEST_DATA_2)
|
||||
name_col = self.table.columns['name']
|
||||
name_col.auto = "form_field"
|
||||
|
||||
row = self.table.get_rows()[0]
|
||||
name_cell = row.cells['name']
|
||||
name_cell.inline_edit_mod = True
|
||||
|
||||
# Check if in-line edit is available in the cell,
|
||||
# and is in inline_edit_mod, also column auto must be
|
||||
# set as form_field.
|
||||
self.assertEqual(name_cell.inline_edit_available,
|
||||
True)
|
||||
self.assertEqual(name_cell.inline_edit_mod,
|
||||
True)
|
||||
self.assertEqual(name_col.auto,
|
||||
'form_field')
|
||||
|
||||
# Check if is cell is rendered correctly.
|
||||
name_cell_rendered = name_cell.render()
|
||||
resp = http.HttpResponse(name_cell_rendered)
|
||||
|
||||
self.assertContains(resp,
|
||||
'<input class="test" id="name__1" name="name__1"'
|
||||
' type="text" value="custom object_1" />',
|
||||
count=1, html=True)
|
||||
|
||||
self.assertContains(resp, '<td', 1)
|
||||
self.assertContains(resp, 'inline_edit_available', 1)
|
||||
self.assertContains(resp,
|
||||
'data-update-url="?action=cell_update&'
|
||||
'table=my_table&cell_name=name&obj_id=1"',
|
||||
1)
|
||||
self.assertContains(resp, 'table_cell_wrapper', 1)
|
||||
self.assertContains(resp, 'inline-edit-error', 1)
|
||||
self.assertContains(resp, 'inline-edit-form', 1)
|
||||
self.assertContains(resp, 'inline-edit-actions', 1)
|
||||
self.assertContains(resp, 'inline-edit-submit', 1)
|
||||
self.assertContains(resp, 'inline-edit-cancel', 1)
|
||||
|
||||
def test_inline_edit_mod_checkbox_with_label(self):
|
||||
class TempTable(MyTable):
|
||||
name = tables.Column(get_name,
|
||||
verbose_name="Verbose Name",
|
||||
sortable=True,
|
||||
form_field=forms.BooleanField(
|
||||
required=True,
|
||||
label="Verbose Name"),
|
||||
form_field_attributes={'class': 'test'},
|
||||
update_action=MyUpdateAction)
|
||||
|
||||
class Meta:
|
||||
name = "my_table"
|
||||
columns = ('id', 'name', 'value', 'optional', 'status')
|
||||
|
||||
self.table = TempTable(self.request, TEST_DATA_2)
|
||||
name_col = self.table.columns['name']
|
||||
name_col.auto = "form_field"
|
||||
|
||||
row = self.table.get_rows()[0]
|
||||
name_cell = row.cells['name']
|
||||
name_cell.inline_edit_mod = True
|
||||
|
||||
# Check if is cell is rendered correctly.
|
||||
name_cell_rendered = name_cell.render()
|
||||
resp = http.HttpResponse(name_cell_rendered)
|
||||
|
||||
self.assertContains(resp,
|
||||
'<input checked="checked" class="test" '
|
||||
'id="name__1" name="name__1" type="checkbox" '
|
||||
'value="custom object_1" />',
|
||||
count=1, html=True)
|
||||
self.assertContains(resp,
|
||||
'<label class="inline-edit-label" for="name__1">'
|
||||
'Verbose Name</label>',
|
||||
count=1, html=True)
|
||||
|
||||
def test_inline_edit_mod_textarea(self):
|
||||
class TempTable(MyTable):
|
||||
name = tables.Column(get_name,
|
||||
verbose_name="Verbose Name",
|
||||
sortable=True,
|
||||
form_field=forms.CharField(
|
||||
widget=forms.Textarea(),
|
||||
required=False),
|
||||
form_field_attributes={'class': 'test'},
|
||||
update_action=MyUpdateAction)
|
||||
|
||||
class Meta:
|
||||
name = "my_table"
|
||||
columns = ('id', 'name', 'value', 'optional', 'status')
|
||||
|
||||
self.table = TempTable(self.request, TEST_DATA_2)
|
||||
name_col = self.table.columns['name']
|
||||
name_col.auto = "form_field"
|
||||
|
||||
row = self.table.get_rows()[0]
|
||||
name_cell = row.cells['name']
|
||||
name_cell.inline_edit_mod = True
|
||||
|
||||
# Check if is cell is rendered correctly.
|
||||
name_cell_rendered = name_cell.render()
|
||||
resp = http.HttpResponse(name_cell_rendered)
|
||||
|
||||
self.assertContains(resp,
|
||||
'<textarea class="test" cols="40" id="name__1" '
|
||||
'name="name__1" rows="10">\r\ncustom object_1'
|
||||
'</textarea>',
|
||||
count=1, html=True)
|
||||
|
||||
def test_table_actions(self):
|
||||
# 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&'
|
||||
'table=my_table&cell_name=name&obj_id=1"',
|
||||
1)
|
||||
self.assertContains(resp, 'table_cell_wrapper', 1)
|
||||
self.assertContains(resp, 'table_cell_data_wrapper', 1)
|
||||
self.assertContains(resp, 'table_cell_action', 1)
|
||||
self.assertContains(resp, 'ajax-inline-edit', 1)
|
||||
|
||||
def test_inline_edit_update_action_get_not_allowed(self):
|
||||
# Name column has required validation, sending blank
|
||||
# will return error.
|
||||
url = ('/my_url/?action=cell_update'
|
||||
'&table=my_table&cell_name=name&obj_id=1')
|
||||
req = self.factory.post(url, {})
|
||||
self.table = MyTableNotAllowedInlineEdit(req, TEST_DATA_2)
|
||||
handled = self.table.maybe_preempt()
|
||||
# Checking the response header.
|
||||
self.assertEqual(handled.status_code, 401)
|
||||
|
||||
def test_inline_edit_update_action_get_inline_edit_mod(self):
|
||||
# Get request in inline_edit_mode should return td with form field.
|
||||
url = ('/my_url/?inline_edit_mod=true&action=cell_update'
|
||||
'&table=my_table&cell_name=name&obj_id=1')
|
||||
req = self.factory.get(url, {},
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.table = MyTable(req, TEST_DATA_2)
|
||||
handled = self.table.maybe_preempt()
|
||||
# Checking the response header.
|
||||
self.assertEqual(handled.status_code, 200)
|
||||
# Checking the response content.
|
||||
resp = handled
|
||||
self.assertContains(resp,
|
||||
'<input class="test" id="name__1" name="name__1"'
|
||||
' type="text" value="custom object_1" />',
|
||||
count=1, html=True)
|
||||
|
||||
self.assertContains(resp, '<td', 1)
|
||||
self.assertContains(resp, 'inline_edit_available', 1)
|
||||
self.assertContains(
|
||||
resp,
|
||||
'data-update-url="/my_url/?action=cell_update&'
|
||||
'table=my_table&cell_name=name&obj_id=1"',
|
||||
1)
|
||||
self.assertContains(resp, 'table_cell_wrapper', 1)
|
||||
self.assertContains(resp, 'inline-edit-error', 1)
|
||||
self.assertContains(resp, 'inline-edit-form', 1)
|
||||
self.assertContains(resp, 'inline-edit-actions', 1)
|
||||
self.assertContains(resp, '<button', 2)
|
||||
self.assertContains(resp, 'inline-edit-submit', 1)
|
||||
self.assertContains(resp, 'inline-edit-cancel', 1)
|
||||
|
||||
def test_inline_edit_update_action_post(self):
|
||||
# Post request should invoke the cell update table action.
|
||||
url = ('/my_url/?action=cell_update'
|
||||
'&table=my_table&cell_name=name&obj_id=1')
|
||||
req = self.factory.post(url, {'name__1': 'test_name'})
|
||||
self.table = MyTable(req, TEST_DATA_2)
|
||||
# checking the response header
|
||||
handled = self.table.maybe_preempt()
|
||||
self.assertEqual(handled.status_code, 200)
|
||||
|
||||
def test_inline_edit_update_action_post_not_allowed(self):
|
||||
# Post request should invoke the cell update table action.
|
||||
url = ('/my_url/?action=cell_update'
|
||||
'&table=my_table&cell_name=name&obj_id=1')
|
||||
req = self.factory.post(url, {'name__1': 'test_name'})
|
||||
self.table = MyTableNotAllowedInlineEdit(req, TEST_DATA_2)
|
||||
# checking the response header
|
||||
handled = self.table.maybe_preempt()
|
||||
self.assertEqual(handled.status_code, 401)
|
||||
|
||||
def test_inline_edit_update_action_post_validation_error(self):
|
||||
# Name column has required validation, sending blank
|
||||
# will return error.
|
||||
url = ('/my_url/?action=cell_update'
|
||||
'&table=my_table&cell_name=name&obj_id=1')
|
||||
req = self.factory.post(url, {})
|
||||
self.table = MyTable(req, TEST_DATA_2)
|
||||
handled = self.table.maybe_preempt()
|
||||
# Checking the response header.
|
||||
self.assertEqual(handled.status_code, 400)
|
||||
self.assertEqual(handled._headers['content-type'],
|
||||
('Content-Type', 'application/json'))
|
||||
# Checking the response content.
|
||||
resp = handled
|
||||
self.assertContains(resp,
|
||||
'"message": "This field is required."',
|
||||
count=1, status_code=400)
|
||||
|
||||
def test_column_uniqueness(self):
|
||||
table1 = MyTable(self.request)
|
||||
table2 = MyTable(self.request)
|
||||
|
@ -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,
|
||||
|
@ -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'")
|
||||
|
BIN
openstack_dashboard/static/dashboard/img/spinner.gif
Normal file
BIN
openstack_dashboard/static/dashboard/img/spinner.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.6 KiB |
@ -607,7 +607,202 @@ table form {
|
||||
button.btn-delete, button.btn-terminate {
|
||||
.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 {
|
||||
|
Loading…
Reference in New Issue
Block a user