Removed inline_edit functionality for tables

As per agreed in Austin summit and then confirmed in the midcycle,
the inline_edit form/functionality is more an issue than a solution.
It leads to many bugs and UX is not the best.

Implements: blueprint remove-table-inline-edit
Change-Id: I91394e4a63afadb66659b6b7c04cb7f8da948ff8
This commit is contained in:
Luis Daniel Castellanos 2016-07-18 13:54:06 -05:00 committed by Rob Cresswell
parent 528358cbbd
commit 241eda4273
12 changed files with 41 additions and 316 deletions

View File

@ -90,7 +90,7 @@ Actions
.. autoclass:: DeleteAction
:members:
.. autoclass:: UpdateAction
.. autoclass:: UpdateAction **DEPRECATED**
:members:
Class-Based Views

View File

@ -288,8 +288,8 @@ Example::
admin=True)
return project_info
Updating changed cell data
--------------------------
Updating changed cell data (DEPRECATED)
---------------------------------------
Define an ``update_cell`` method in the class inherited from
``tables.UpdateAction``. This method takes care of saving the data of the

View File

@ -287,7 +287,7 @@ There are also additional actions which are extensions of the basic Action class
- :class:`~horizon.tables.BatchAction`
- :class:`~horizon.tables.DeleteAction`
- :class:`~horizon.tables.UpdateAction`
- :class:`~horizon.tables.UpdateAction` **DEPRECATED**
- :class:`~horizon.tables.FixedFilterAction`

View File

@ -1,3 +1,4 @@
//TODO(lcastell):Inline edit is deprecated and will be removed in Horizon 12.0
horizon.inline_edit = {
get_cell_id: function (td_element) {
return [

View File

@ -108,4 +108,5 @@ horizon.addInitFunction(horizon.tabs.init = function () {
});
});
//TODO(lcastell):Inline edit is deprecated and will be removed in Horizon 12.0
horizon.tabs.addTabLoadFunction(horizon.inline_edit.init);

View File

@ -961,8 +961,26 @@ class DeleteAction(BatchAction):
"""
class Deprecated(type):
# TODO(lcastell) Replace class with similar functionality from
# oslo_log.versionutils when it's finally added in 11.0
def __new__(meta, name, bases, kwargs):
cls = super(Deprecated, meta).__new__(meta, name, bases, kwargs)
message = ("WARNING:The UpdateAction class defined in module '%s'"
" is deprecated as of Newton and may be removed in "
"Horizon P (12.0). Class '%s' defined at module '%s' "
"shall no longer subclass it.")
if name != 'UpdateAction':
LOG.warning(message % (UpdateAction.__module__,
name,
kwargs['__module__']))
return cls
@six.add_metaclass(Deprecated)
class UpdateAction(object):
"""A table action for cell updates by inline editing."""
name = "update"
def action(self, request, datum, obj_id, cell_name, new_cell_value):

View File

@ -16,7 +16,6 @@ from django.template import defaultfilters as filters
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext_lazy
from horizon import exceptions
from horizon import forms
from horizon import tables
@ -95,33 +94,6 @@ class UpdateRow(tables.Row):
wrap=True)
class UpdateCell(tables.UpdateAction):
policy_rules = (("image", "modify_metadef_namespace"),)
def update_cell(self, request, datum, namespace_name,
cell_name, new_cell_value):
# inline update namespace info
try:
namespace_obj = datum
# updating changed value by new value
if cell_name == 'public':
cell_name = 'visibility'
if new_cell_value:
new_cell_value = 'public'
else:
new_cell_value = 'private'
setattr(namespace_obj, cell_name, new_cell_value)
properties = {cell_name: new_cell_value}
glance.metadefs_namespace_update(
request,
namespace_name,
**properties)
except Exception:
exceptions.handle(request, ignore=True)
return False
return True
class AdminNamespacesTable(tables.DataTable):
display_name = tables.Column(
"display_name",
@ -143,15 +115,13 @@ class AdminNamespacesTable(tables.DataTable):
verbose_name=_("Public"),
empty_value=False,
form_field=forms.BooleanField(required=False),
filters=(filters.yesno, filters.capfirst),
update_action=UpdateCell)
filters=(filters.yesno, filters.capfirst))
protected = tables.Column(
"protected",
verbose_name=_("Protected"),
empty_value=False,
form_field=forms.BooleanField(required=False),
filters=(filters.yesno, filters.capfirst),
update_action=UpdateCell)
filters=(filters.yesno, filters.capfirst))
def get_object_id(self, datum):
return datum.namespace

View File

@ -20,7 +20,6 @@ from horizon import forms
from horizon import tables
from openstack_dashboard.api import cinder
from openstack_dashboard import policy
class CreateVolumeType(tables.LinkAction):
@ -196,51 +195,14 @@ class UpdateRow(tables.Row):
return volume_type
class UpdateCell(tables.UpdateAction):
def allowed(self, request, volume_type, cell):
return policy.check(
("volume_extension", "volume_extension:types_manage"), request)
def update_cell(self, request, data, volume_type_id,
cell_name, new_cell_value):
# inline update volume type name and/or description
try:
vol_type_obj = data
# updating changed value by new value
setattr(vol_type_obj, cell_name, new_cell_value)
name_value = getattr(vol_type_obj, 'name', None)
desc_value = getattr(vol_type_obj, 'description', None)
public_value = getattr(vol_type_obj, 'public', None)
cinder.volume_type_update(
request,
volume_type_id,
name=name_value,
description=desc_value,
is_public=public_value)
except Exception as ex:
if ex.code and ex.code == 409:
error_message = _('New name conflicts with another '
'volume type.')
else:
error_message = _('Unable to update the volume type.')
exceptions.handle(request, error_message)
return False
return True
class VolumeTypesTable(tables.DataTable):
name = tables.Column("name", verbose_name=_("Name"),
form_field=forms.CharField(
max_length=64),
update_action=UpdateCell)
form_field=forms.CharField(max_length=64))
description = tables.Column(lambda obj: getattr(obj, 'description', None),
verbose_name=_('Description'),
form_field=forms.CharField(
widget=forms.Textarea(attrs={'rows': 4}),
required=False),
update_action=UpdateCell)
required=False))
assoc_qos_spec = tables.Column("associated_qos_spec",
verbose_name=_("Associated QoS Spec"))
@ -250,7 +212,6 @@ class VolumeTypesTable(tables.DataTable):
public = tables.Column("is_public",
verbose_name=_("Public"),
filters=(filters.yesno, filters.capfirst),
update_action=UpdateCell,
form_field=forms.BooleanField(
label=_('Public'), required=False))

View File

@ -10,17 +10,14 @@
# 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
from django.template import defaultfilters as filters
from django.utils.http import urlencode
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext_lazy
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 import policy
@ -220,58 +217,21 @@ class UpdateRow(tables.Row):
return project_info
class UpdateCell(tables.UpdateAction):
def allowed(self, request, project, cell):
policy_rule = (("identity", "identity:update_project"),)
return (
(cell.column.name != 'enabled' or
request.user.project_id != cell.datum.id) and
api.keystone.keystone_can_edit_project() and
policy.check(policy_rule, request))
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'),
link=("horizon:identity:projects:detail"),
form_field=forms.CharField(max_length=64),
update_action=UpdateCell)
form_field=forms.CharField(max_length=64))
description = tables.Column(lambda obj: getattr(obj, 'description', None),
verbose_name=_('Description'),
form_field=forms.CharField(
widget=forms.Textarea(attrs={'rows': 4}),
required=False),
update_action=UpdateCell)
required=False))
id = tables.Column('id', verbose_name=_('Project ID'))
enabled = tables.Column('enabled', verbose_name=_('Enabled'), status=True,
filters=(filters.yesno, filters.capfirst),
form_field=forms.BooleanField(
label=_('Enabled'),
required=False),
update_action=UpdateCell)
required=False))
if api.keystone.VERSIONS.active >= 3:
domain_name = tables.Column(
@ -281,8 +241,7 @@ class TenantsTable(tables.DataTable):
filters=(filters.yesno, filters.capfirst),
form_field=forms.BooleanField(
label=_('Enabled'),
required=False),
update_action=UpdateCell)
required=False))
def get_project_detail_link(self, project):
# this method is an ugly monkey patch, needed because

View File

@ -12,7 +12,6 @@
# License for the specific language governing permissions and limitations
# under the License.
import copy
import datetime
import logging
import os
@ -36,13 +35,6 @@ from openstack_dashboard.test import helpers as test
from openstack_dashboard import usage
from openstack_dashboard.usage import quotas
with_sel = os.environ.get('WITH_SELENIUM', False)
if with_sel:
from selenium.webdriver import ActionChains # noqa
from selenium.webdriver.common import keys
from socket import timeout as socket_timeout # noqa
INDEX_URL = reverse('horizon:identity:projects:index')
USER_ROLE_PREFIX = workflows.PROJECT_USER_MEMBER_SLUG + "_role_"
@ -1637,143 +1629,6 @@ class DetailProjectViewTests(test.BaseAdminViewTests):
@unittest.skipUnless(os.environ.get('WITH_SELENIUM', False),
"The WITH_SELENIUM env variable is not set.")
class SeleniumTests(test.SeleniumAdminTestCase):
@test.create_stubs(
{api.keystone: ('tenant_list', 'tenant_get', 'tenant_update',
'domain_lookup')})
def test_inline_editing_update(self):
# Tenant List
api.keystone.tenant_list(IgnoreArg(),
domain=None,
marker=None,
paginate=True) \
.AndReturn([self.tenants.list(), False])
api.keystone.domain_lookup(IgnoreArg()).AndReturn({None: None})
# 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='/identity/?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='/identity/?action=cell_update"
"&table=tenants&cell_name=name&obj_id=1']")
name_input = td_element.find_element_by_tag_name('input')
name_input.send_keys(keys.Keys.HOME)
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='/identity/?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='/identity/?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', 'domain_lookup')})
def test_inline_editing_cancel(self):
# Tenant List
api.keystone.tenant_list(IgnoreArg(),
domain=None,
marker=None,
paginate=True) \
.AndReturn([self.tenants.list(), False])
api.keystone.domain_lookup(IgnoreArg()).AndReturn({None: None})
# 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='/identity/?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='/identity/?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='/identity/?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'")
@test.create_stubs({api.keystone: ('get_default_domain',
'get_default_role',
'user_list',

View File

@ -10,14 +10,11 @@
# License for the specific language governing permissions and limitations
# under the License.
from django.core import exceptions as django_exceptions
from django.template import defaultfilters
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext_lazy
from horizon import exceptions as horizon_exceptions
from horizon import forms
from horizon import messages
from horizon import tables
from openstack_dashboard import api
from openstack_dashboard import policy
@ -176,47 +173,6 @@ class UpdateRow(tables.Row):
return user_info
class UpdateCell(tables.UpdateAction):
def allowed(self, request, user, cell):
return api.keystone.keystone_can_edit_user() and \
policy.check((("identity", "identity:update_user"),),
request)
def update_cell(self, request, datum, user_id,
cell_name, new_cell_value):
try:
user_obj = datum
setattr(user_obj, cell_name, new_cell_value)
if ((not new_cell_value) or new_cell_value.isspace()) and \
(cell_name == 'name'):
message = _("The User Name field cannot be empty.")
messages.warning(request, message)
raise django_exceptions.ValidationError(message)
kwargs = {}
attr_to_keyword_map = {
'name': 'name',
'description': 'description',
'email': 'email',
'enabled': 'enabled',
'project_id': 'project'
}
for key in attr_to_keyword_map:
value = getattr(user_obj, key, None)
keyword_name = attr_to_keyword_map[key]
if value is not None:
kwargs[keyword_name] = value
api.keystone.user_update(request, user_obj, **kwargs)
except horizon_exceptions.Conflict:
message = _("This name is already taken.")
messages.warning(request, message)
raise django_exceptions.ValidationError(message)
except Exception:
horizon_exceptions.handle(request, ignore=True)
return False
return True
class UsersTable(tables.DataTable):
STATUS_CHOICES = (
("true", True),
@ -225,19 +181,16 @@ class UsersTable(tables.DataTable):
name = tables.Column('name',
link="horizon:identity:users:detail",
verbose_name=_('User Name'),
form_field=forms.CharField(required=False),
update_action=UpdateCell)
form_field=forms.CharField(required=False))
description = tables.Column(lambda obj: getattr(obj, 'description', None),
verbose_name=_('Description'),
hidden=KEYSTONE_V2_ENABLED,
form_field=forms.CharField(
widget=forms.Textarea(attrs={'rows': 4}),
required=False),
update_action=UpdateCell)
required=False))
email = tables.Column(lambda obj: getattr(obj, 'email', None),
verbose_name=_('Email'),
form_field=forms.EmailField(required=False),
update_action=UpdateCell,
filters=(lambda v: defaultfilters
.default_if_none(v, ""),
defaultfilters.escape,

View File

@ -0,0 +1,7 @@
---
deprecations:
- Inline Edit functionality for Horizon tables is now deprecated and will be
removed in Horizon P (12.0)
The functionality was removed from the following tables.
Admin Volume Types table, Admin Metadata Definitions table, Identity
Projects table and Identity Users table