Browse Source

Merge "Inline Table editing"

Jenkins 5 years ago
parent
commit
efc88d4078

+ 3
- 0
doc/source/ref/tables.rst View File

@@ -76,6 +76,9 @@ Actions
76 76
 .. autoclass:: DeleteAction
77 77
     :members:
78 78
 
79
+.. autoclass:: UpdateAction
80
+    :members:
81
+
79 82
 Class-Based Views
80 83
 =================
81 84
 

+ 135
- 0
doc/source/topics/tables.rst View File

@@ -246,3 +246,138 @@ So it's enough to just import and use them, e.g. ::
246 246
 
247 247
     # code omitted
248 248
     filters=(parse_isotime, timesince)
249
+
250
+
251
+Inline editing
252
+==============
253
+
254
+Table cells can be easily upgraded with in-line editing. With use of
255
+django.form.Field, we are able to run validations of the field and correctly
256
+parse the data. The updating process is fully encapsulated into table
257
+functionality, communication with the server goes through AJAX in JSON format.
258
+The javacript wrapper for inline editing allows each table cell that has
259
+in-line editing available to:
260
+  #. Refresh itself with new data from the server.
261
+  #. Display in edit mod.
262
+  #. Send changed data to server.
263
+  #. Display validation errors.
264
+
265
+There are basically 3 things that need to be defined in the table in order
266
+to enable in-line editing.
267
+
268
+Fetching the row data
269
+---------------------
270
+
271
+Defining an ``get_data`` method in a class inherited from ``tables.Row``.
272
+This method takes care of fetching the row data. This class has to be then
273
+defined in the table Meta class as ``row_class = UpdateRow``.
274
+
275
+Example::
276
+
277
+    class UpdateRow(tables.Row):
278
+        # this method is also used for automatic update of the row
279
+        ajax = True
280
+
281
+        def get_data(self, request, project_id):
282
+            # getting all data of all row cells
283
+            project_info = api.keystone.tenant_get(request, project_id,
284
+                                                   admin=True)
285
+            return project_info
286
+
287
+Updating changed cell data
288
+--------------------------
289
+
290
+Define an ``update_cell`` method in the class inherited from
291
+``tables.UpdateAction``. This method takes care of saving the data of the
292
+table cell. There can be one class for every cell thanks to the
293
+``cell_name`` parameter. This class is then defined in tables column as
294
+``update_action=UpdateCell``, so each column can have its own updating
295
+method.
296
+
297
+Example::
298
+
299
+    class UpdateCell(tables.UpdateAction):
300
+        def allowed(self, request, project, cell):
301
+            # Determines whether given cell or row will be inline editable
302
+            # for signed in user.
303
+            return api.keystone.keystone_can_edit_project()
304
+
305
+        def update_cell(self, request, project_id, cell_name, new_cell_value):
306
+            # in-line update project info
307
+            try:
308
+                project_obj = datum
309
+                # updating changed value by new value
310
+                setattr(project_obj, cell_name, new_cell_value)
311
+
312
+                # sending new attributes back to API
313
+                api.keystone.tenant_update(
314
+                    request,
315
+                    project_id,
316
+                    name=project_obj.name,
317
+                    description=project_obj.description,
318
+                    enabled=project_obj.enabled)
319
+
320
+            except Conflict:
321
+                # Validation error for naming conflict, raised when user
322
+                # choose the existing name. We will raise a
323
+                # ValidationError, that will be sent back to the client
324
+                # browser and shown inside of the table cell.
325
+                message = _("This name is already taken.")
326
+                raise ValidationError(message)
327
+            except:
328
+                # Other exception of the API just goes through standard
329
+                # channel
330
+                exceptions.handle(request, ignore=True)
331
+                return False
332
+            return True
333
+
334
+Defining a form_field for each Column that we want to be in-line edited.
335
+------------------------------------------------------------------------
336
+
337
+Form field should be ``django.form.Field`` instance, so we can use django
338
+validations and parsing of the values sent by POST (in example validation
339
+``required=True`` and correct parsing of the checkbox value from the POST
340
+data).
341
+
342
+Form field can be also ``django.form.Widget`` class, if we need to just
343
+display the form widget in the table and we don't need Field functionality.
344
+
345
+Then connecting ``UpdateRow`` and ``UpdateCell`` classes to the table.
346
+
347
+Example::
348
+
349
+    class TenantsTable(tables.DataTable):
350
+        # Adding html text input for inline editing, with required validation.
351
+        # HTML form input will have a class attribute tenant-name-input, we
352
+        # can define here any HTML attribute we need.
353
+        name = tables.Column('name', verbose_name=_('Name'),
354
+                             form_field=forms.CharField(required=True),
355
+                             form_field_attributes={'class':'tenant-name-input'},
356
+                             update_action=UpdateCell)
357
+
358
+        # Adding html textarea without required validation.
359
+        description = tables.Column(lambda obj: getattr(obj, 'description', None),
360
+                                    verbose_name=_('Description'),
361
+                                    form_field=forms.CharField(
362
+                                        widget=forms.Textarea(),
363
+                                        required=False),
364
+                                    update_action=UpdateCell)
365
+
366
+        # Id will not be inline edited.
367
+        id = tables.Column('id', verbose_name=_('Project ID'))
368
+
369
+        # Adding html checkbox, that will be shown inside of the table cell with
370
+        # label
371
+        enabled = tables.Column('enabled', verbose_name=_('Enabled'), status=True,
372
+                                form_field=forms.BooleanField(
373
+                                    label=_('Enabled'),
374
+                                    required=False),
375
+                                update_action=UpdateCell)
376
+
377
+        class Meta:
378
+            name = "tenants"
379
+            verbose_name = _("Projects")
380
+            # Connection to UpdateRow, so table can fetch row data based on
381
+            # their primary key.
382
+            row_class = UpdateRow
383
+

+ 13
- 13
horizon/static/horizon/js/horizon.tables.js View File

@@ -76,9 +76,9 @@ horizon.datatables = {
76 76
 
77 77
             // Only replace row if the html content has changed
78 78
             if($new_row.html() != $row.html()) {
79
-              if($row.find(':checkbox').is(':checked')) {
79
+              if($row.find('.table-row-multi-select:checkbox').is(':checked')) {
80 80
                 // Preserve the checkbox if it's already clicked
81
-                $new_row.find(':checkbox').prop('checked', true);
81
+                $new_row.find('.table-row-multi-select:checkbox').prop('checked', true);
82 82
               }
83 83
               $row.replaceWith($new_row);
84 84
               // Reset tablesorter's data cache.
@@ -112,7 +112,7 @@ horizon.datatables = {
112 112
   validate_button: function () {
113 113
     // Disable form button if checkbox are not checked
114 114
     $("form").each(function (i) {
115
-      var checkboxes = $(this).find(":checkbox");
115
+      var checkboxes = $(this).find(".table-row-multi-select:checkbox");
116 116
       if(!checkboxes.length) {
117 117
         // Do nothing if no checkboxes in this form
118 118
         return;
@@ -142,7 +142,7 @@ horizon.datatables.confirm = function (action) {
142 142
   if ($("#"+closest_table_id+" tr[data-display]").length > 0) {
143 143
     if($(action).closest("div").hasClass("table_actions")) {
144 144
       // One or more checkboxes selected
145
-      $("#"+closest_table_id+" tr[data-display]").has(":checkbox:checked").each(function() {
145
+      $("#"+closest_table_id+" tr[data-display]").has(".table-row-multi-select:checkbox:checked").each(function() {
146 146
         name_array.push(" \"" + $(this).attr("data-display") + "\"");
147 147
       });
148 148
       name_array.join(", ");
@@ -290,8 +290,8 @@ $(parent).find("table.datatable").each(function () {
290 290
 
291 291
 horizon.datatables.add_table_checkboxes = function(parent) {
292 292
   $(parent).find('table thead .multi_select_column').each(function(index, thead) {
293
-    if (!$(thead).find(':checkbox').length &&
294
-        $(thead).parents('table').find('tbody :checkbox').length) {
293
+    if (!$(thead).find('.table-row-multi-select:checkbox').length &&
294
+        $(thead).parents('table').find('tbody .table-row-multi-select:checkbox').length) {
295 295
       $(thead).append('<input type="checkbox">');
296 296
     }
297 297
   });
@@ -377,24 +377,24 @@ horizon.addInitFunction(function() {
377 377
     horizon.datatables.update_footer_count($(el), 0);
378 378
   });
379 379
   // Bind the "select all" checkbox action.
380
-  $('div.table_wrapper, #modal_wrapper').on('click', 'table thead .multi_select_column :checkbox', function(evt) {
380
+  $('div.table_wrapper, #modal_wrapper').on('click', 'table thead .multi_select_column .table-row-multi-select:checkbox', function(evt) {
381 381
     var $this = $(this),
382 382
         $table = $this.closest('table'),
383 383
         is_checked = $this.prop('checked'),
384
-        checkboxes = $table.find('tbody :visible:checkbox');
384
+        checkboxes = $table.find('tbody .table-row-multi-select:visible:checkbox');
385 385
     checkboxes.prop('checked', is_checked);
386 386
   });
387 387
   // Change "select all" checkbox behaviour while any checkbox is checked/unchecked.
388
-  $("div.table_wrapper, #modal_wrapper").on("click", 'table tbody :checkbox', function (evt) {
388
+  $("div.table_wrapper, #modal_wrapper").on("click", 'table tbody .table-row-multi-select:checkbox', function (evt) {
389 389
     var $table = $(this).closest('table');
390
-    var $multi_select_checkbox = $table.find('thead .multi_select_column :checkbox'); 
391
-    var any_unchecked = $table.find("tbody :checkbox").not(":checked");
390
+    var $multi_select_checkbox = $table.find('thead .multi_select_column .table-row-multi-select:checkbox');
391
+    var any_unchecked = $table.find("tbody .table-row-multi-select:checkbox").not(":checked");
392 392
     $multi_select_checkbox.prop('checked', any_unchecked.length === 0);
393 393
   });
394 394
   // Enable dangerous buttons only if one or more checkbox is checked.
395
-  $("div.table_wrapper, #modal_wrapper").on("click", ':checkbox', function (evt) {
395
+  $("div.table_wrapper, #modal_wrapper").on("click", '.table-row-multi-select:checkbox', function (evt) {
396 396
     var $form = $(this).closest("form");
397
-    var any_checked = $form.find("tbody :checkbox").is(":checked");
397
+    var any_checked = $form.find("tbody .table-row-multi-select:checkbox").is(":checked");
398 398
     if(any_checked) {
399 399
       $form.find(".table_actions button.btn-danger").removeClass("disabled");
400 400
     }else {

+ 271
- 0
horizon/static/horizon/js/horizon.tables_inline_edit.js View File

@@ -0,0 +1,271 @@
1
+horizon.inline_edit = {
2
+  get_cell_id: function (td_element) {
3
+    return (td_element.parents("tr").first().data("object-id")
4
+      + "__" + td_element.data("cell-name"));
5
+  },
6
+  get_object_container: function (td_element) {
7
+    // global cell object container
8
+    if (!window.cell_object_container) {
9
+      window.cell_object_container = new Array();
10
+    }
11
+    return window.cell_object_container;
12
+  },
13
+  get_cell_object: function (td_element) {
14
+    var cell_id = horizon.inline_edit.get_cell_id(td_element);
15
+    var id = "cell__" + cell_id;
16
+    var container = horizon.inline_edit.get_object_container(td_element);
17
+    if (container && container[id]){
18
+      // if cell object exists, I will reuse it
19
+      var cell_object = container[id];
20
+      cell_object.reset_with(td_element);
21
+      return cell_object;
22
+    } else {
23
+      // or I will create new cell object
24
+      var cell_object = new horizon.inline_edit.Cell(td_element);
25
+      // saving cell object to global container
26
+      container[id] = cell_object;
27
+      return cell_object;
28
+    }
29
+  },
30
+  Cell: function (td_element){
31
+    var self = this;
32
+
33
+    // setting initial attributes
34
+    self.reset_with = function(td_element){
35
+      self.td_element = td_element;
36
+      self.form_element = td_element.find("input, textarea");
37
+      self.url = td_element.data('update-url');
38
+      self.inline_edit_mod = false;
39
+      self.successful_update = false;
40
+    };
41
+    self.reset_with(td_element);
42
+
43
+    self.refresh = function () {
44
+      horizon.ajax.queue({
45
+        url: self.url,
46
+        data: {'inline_edit_mod': self.inline_edit_mod},
47
+        beforeSend: function () {
48
+          self.start_loading();
49
+        },
50
+        complete: function () {
51
+          // Bug in Jquery tool-tip, if I hover tool-tip, then confirm the field with
52
+          // enter and the cell is reloaded, tool-tip stays. So just to be sure, I am
53
+          // removing tool-tips manually
54
+          $(".tooltip.fade.top.in").remove();
55
+          self.stop_loading();
56
+
57
+          if (self.successful_update) {
58
+            // if cell was updated successfully, I will show fading check sign
59
+            var success = $('<div class="success"></div>');
60
+            self.td_element.find('.inline-edit-status').append(success);
61
+
62
+            var background_color = self.td_element.css('background-color');
63
+
64
+            // edit pencil will disappear and appear again once the check sign has faded
65
+            // also green background will disappear
66
+            self.td_element.addClass("no-transition");
67
+            self.td_element.addClass("success");
68
+            self.td_element.removeClass("no-transition");
69
+
70
+            self.td_element.removeClass("inline_edit_available");
71
+
72
+            success.fadeOut(1300, function () {
73
+              self.td_element.addClass("inline_edit_available");
74
+              self.td_element.removeClass("success");
75
+            });
76
+          }
77
+        },
78
+        error: function(jqXHR, status, errorThrown) {
79
+          if (jqXHR.status === 401){
80
+            var redir_url = jqXHR.getResponseHeader("X-Horizon-Location");
81
+            if (redir_url){
82
+              location.href = redir_url;
83
+            } else {
84
+              horizon.alert("error", gettext("Not authorized to do this operation."));
85
+            }
86
+          }
87
+          else {
88
+            if (!horizon.ajax.get_messages(jqXHR)) {
89
+              // Generic error handler. Really generic.
90
+              horizon.alert("error", gettext("An error occurred. Please try again later."));
91
+            }
92
+          }
93
+        },
94
+        success: function (data, textStatus, jqXHR) {
95
+          var td_element = $(data);
96
+          self.form_element = self.get_form_element(td_element);
97
+
98
+          if (self.inline_edit_mod) {
99
+            // if cell is in inline edit mode
100
+            var table_cell_wrapper = td_element.find(".table_cell_wrapper");
101
+
102
+            width = self.td_element.outerWidth();
103
+            height = self.td_element.outerHeight();
104
+
105
+            td_element.width(width);
106
+            td_element.height(height);
107
+            td_element.css('margin', 0).css('padding', 0);
108
+            table_cell_wrapper.css('margin', 0).css('padding', 0);
109
+
110
+            if (self.form_element.attr('type')=='checkbox'){
111
+              var inline_edit_form = td_element.find(".inline-edit-form");
112
+              inline_edit_form.css('padding-top', '11px').css('padding-left', '4px');
113
+              inline_edit_form.width(width - 40);
114
+            } else {
115
+              // setting CSS of element, so the cell remains the same size in editing mode
116
+              self.form_element.width(width - 40);
117
+              self.form_element.height(height - 2);
118
+              self.form_element.css('margin', 0).css('padding', 0);
119
+            }
120
+          }
121
+          // saving old td_element for cancel and loading purposes
122
+          self.cached_presentation_view = self.td_element;
123
+          // replacing old td with the new td element returned from the server
124
+          self.rewrite_cell(td_element);
125
+          // focusing the form element inside the cell
126
+          if (self.inline_edit_mod) {
127
+            self.form_element.focus();
128
+          }
129
+        }
130
+      });
131
+    };
132
+    self.update = function(post_data){
133
+      // make the update request
134
+      horizon.ajax.queue({
135
+        type: 'POST',
136
+        url: self.url,
137
+        data: post_data,
138
+        beforeSend: function () {
139
+          self.start_loading();
140
+        },
141
+        complete: function () {
142
+          if (!self.successful_update){
143
+            self.stop_loading();
144
+          }
145
+        },
146
+        error: function(jqXHR, status, errorThrown) {
147
+          if (jqXHR.status === 400){
148
+            // make place for error icon, only if the error icon is not already present
149
+            if (self.td_element.find(".inline-edit-error .error").length <= 0) {
150
+              self.form_element.css('padding-left', '20px');
151
+              self.form_element.width(self.form_element.width() - 20);
152
+            }
153
+            // obtain the error message from response body
154
+            error_message = $.parseJSON(jqXHR.responseText).message;
155
+            // insert the error icon
156
+            var error = $('<div title="' + error_message + '" class="error"></div>')
157
+            self.td_element.find(".inline-edit-error").html(error);
158
+            error.tooltip({'placement':'top'});
159
+          }
160
+          else if (jqXHR.status === 401){
161
+            var redir_url = jqXHR.getResponseHeader("X-Horizon-Location");
162
+            if (redir_url){
163
+              location.href = redir_url;
164
+            } else {
165
+              horizon.alert("error", gettext("Not authorized to do this operation."));
166
+            }
167
+          }
168
+          else {
169
+            if (!horizon.ajax.get_messages(jqXHR)) {
170
+              // Generic error handler. Really generic.
171
+              horizon.alert("error", gettext("An error occurred. Please try again later."));
172
+            }
173
+          }
174
+        },
175
+        success: function (data, textStatus, jqXHR) {
176
+          // if update was successful
177
+          self.successful_update = true;
178
+          self.refresh();
179
+        }
180
+      });
181
+    };
182
+    self.cancel = function() {
183
+      self.rewrite_cell(self.cached_presentation_view);
184
+      self.stop_loading();
185
+    };
186
+    self.get_form_element = function(td_element){
187
+      return td_element.find("input, textarea");
188
+    };
189
+    self.rewrite_cell = function(td_element){
190
+      self.td_element.replaceWith(td_element);
191
+      self.td_element = td_element;
192
+    };
193
+    self.start_loading = function() {
194
+      self.td_element.addClass("no-transition");
195
+
196
+      var spinner = $('<div class="loading"></div>');
197
+      self.td_element.find('.inline-edit-status').append(spinner);
198
+      self.td_element.addClass("loading");
199
+      self.td_element.removeClass("inline_edit_available");
200
+      self.get_form_element(self.td_element).attr("disabled", "disabled");
201
+    };
202
+    self.stop_loading = function() {
203
+      self.td_element.find('div.inline-edit-status div.loading').remove();
204
+      self.td_element.removeClass("loading");
205
+      self.td_element.addClass("inline_edit_available");
206
+      self.get_form_element(self.td_element).removeAttr("disabled");
207
+    };
208
+  }
209
+};
210
+
211
+
212
+horizon.addInitFunction(function() {
213
+  $('table').on('click', '.ajax-inline-edit', function (evt) {
214
+    var $this = $(this);
215
+    var td_element = $this.parents('td').first();
216
+
217
+    var cell = horizon.inline_edit.get_cell_object(td_element);
218
+    cell.inline_edit_mod = true;
219
+    cell.refresh();
220
+
221
+    evt.preventDefault();
222
+  });
223
+
224
+  var submit_form = function(evt, el){
225
+    var $submit = $(el);
226
+    var td_element = $submit.parents('td').first();
227
+    var post_data = $submit.parents('form').first().serialize();
228
+
229
+    var cell = horizon.inline_edit.get_cell_object(td_element);
230
+    cell.update(post_data);
231
+
232
+    evt.preventDefault();
233
+  }
234
+
235
+  $('table').on('click', '.inline-edit-submit', function (evt) {
236
+    submit_form(evt, this);
237
+  });
238
+
239
+  $('table').on('keypress', '.inline-edit-form', function (evt) {
240
+    if (evt.which == 13 && !evt.shiftKey) {
241
+      submit_form(evt, this);
242
+    }
243
+  });
244
+
245
+  $('table').on('click', '.inline-edit-cancel', function (evt) {
246
+    var $cancel = $(this);
247
+    var td_element = $cancel.parents('td').first();
248
+
249
+    var cell = horizon.inline_edit.get_cell_object(td_element);
250
+    cell.cancel();
251
+
252
+    evt.preventDefault();
253
+  });
254
+
255
+  $('table').on('mouseenter', '.inline_edit_available', function (evt) {
256
+    $(this).find(".table_cell_action").fadeIn(100);
257
+  });
258
+
259
+  $('table').on('mouseleave', '.inline_edit_available', function (evt) {
260
+    $(this).find(".table_cell_action").fadeOut(200);
261
+  });
262
+
263
+  $('table').on('mouseenter', '.table_cell_action', function (evt) {
264
+    $(this).addClass("hovered");
265
+  });
266
+
267
+  $('table').on('mouseleave', '.table_cell_action', function (evt) {
268
+    $(this).removeClass("hovered");
269
+  });
270
+});
271
+

+ 2
- 0
horizon/tables/__init__.py View File

@@ -21,6 +21,7 @@ from horizon.tables.actions import DeleteAction  # noqa
21 21
 from horizon.tables.actions import FilterAction  # noqa
22 22
 from horizon.tables.actions import FixedFilterAction  # noqa
23 23
 from horizon.tables.actions import LinkAction  # noqa
24
+from horizon.tables.actions import UpdateAction  # noqa
24 25
 from horizon.tables.base import Column  # noqa
25 26
 from horizon.tables.base import DataTable  # noqa
26 27
 from horizon.tables.base import Row  # noqa
@@ -33,6 +34,7 @@ assert Action
33 34
 assert BatchAction
34 35
 assert DeleteAction
35 36
 assert LinkAction
37
+assert UpdateAction
36 38
 assert FilterAction
37 39
 assert FixedFilterAction
38 40
 assert DataTable

+ 28
- 0
horizon/tables/actions.py View File

@@ -695,3 +695,31 @@ class DeleteAction(BatchAction):
695 695
         classes = super(DeleteAction, self).get_default_classes()
696 696
         classes += ("btn-danger", "btn-delete")
697 697
         return classes
698
+
699
+
700
+class UpdateAction(object):
701
+    """A table action for cell updates by inline editing."""
702
+    name = "update"
703
+    action_present = _("Update")
704
+    action_past = _("Updated")
705
+    data_type_singular = "update"
706
+
707
+    def action(self, request, datum, obj_id, cell_name, new_cell_value):
708
+        self.update_cell(request, datum, obj_id, cell_name, new_cell_value)
709
+
710
+    def update_cell(self, request, datum, obj_id, cell_name, new_cell_value):
711
+        """Method for saving data of the cell.
712
+
713
+        This method must implements saving logic of the inline edited table
714
+        cell.
715
+        """
716
+        raise NotImplementedError(
717
+            "UpdateAction must define a update_cell method.")
718
+
719
+    def allowed(self, request, datum, cell):
720
+        """Determine whether updating is allowed for the current request.
721
+
722
+        This method is meant to be overridden with more specific checks.
723
+        Data of the row and of the cell are passed to the method.
724
+        """
725
+        return True

+ 194
- 2
horizon/tables/base.py View File

@@ -16,11 +16,13 @@
16 16
 
17 17
 import collections
18 18
 import copy
19
+import json
19 20
 import logging
20 21
 from operator import attrgetter  # noqa
21 22
 import sys
22 23
 
23 24
 from django.conf import settings  # noqa
25
+from django.core import exceptions as core_exceptions
24 26
 from django.core import urlresolvers
25 27
 from django import forms
26 28
 from django.http import HttpResponse  # noqa
@@ -177,6 +179,28 @@ class Column(html.HTMLElement):
177 179
         Boolean value indicating whether the contents of this cell should be
178 180
         wrapped in a ``<ul></ul>`` tag. Useful in conjunction with Django's
179 181
         ``unordered_list`` template filter. Defaults to ``False``.
182
+
183
+    .. attribute:: form_field
184
+
185
+        A form field used for inline editing of the column. A django
186
+        forms.Field can be used or django form.Widget can be used.
187
+
188
+        Example: ``form_field=forms.CharField(required=True)``.
189
+        Defaults to ``None``.
190
+
191
+    .. attribute:: form_field_attributes
192
+
193
+        The additional html attributes that will be rendered to form_field.
194
+        Example: ``form_field_attributes={'class': 'bold_input_field'}``.
195
+        Defaults to ``None``.
196
+
197
+    .. attribute:: update_action
198
+
199
+        The class that inherits from tables.actions.UpdateAction, update_cell
200
+        method takes care of saving inline edited data. The tables.base.Row
201
+        get_data method needs to be connected to table for obtaining the data.
202
+        Example: ``update_action=UpdateCell``.
203
+        Defaults to ``None``.
180 204
     """
181 205
     summation_methods = {
182 206
         "sum": sum,
@@ -210,7 +234,10 @@ class Column(html.HTMLElement):
210 234
                  link=None, allowed_data_types=[], hidden=False, attrs=None,
211 235
                  status=False, status_choices=None, display_choices=None,
212 236
                  empty_value=None, filters=None, classes=None, summation=None,
213
-                 auto=None, truncate=None, link_classes=None, wrap_list=False):
237
+                 auto=None, truncate=None, link_classes=None, wrap_list=False,
238
+                 form_field=None, form_field_attributes=None,
239
+                 update_action=None):
240
+
214 241
         self.classes = list(classes or getattr(self, "classes", []))
215 242
         super(Column, self).__init__()
216 243
         self.attrs.update(attrs or {})
@@ -242,6 +269,9 @@ class Column(html.HTMLElement):
242 269
         self.truncate = truncate
243 270
         self.link_classes = link_classes or []
244 271
         self.wrap_list = wrap_list
272
+        self.form_field = form_field
273
+        self.form_field_attributes = form_field_attributes or {}
274
+        self.update_action = update_action
245 275
 
246 276
         if status_choices:
247 277
             self.status_choices = status_choices
@@ -426,9 +456,17 @@ class Row(html.HTMLElement):
426 456
         String that is used for the query parameter key to request AJAX
427 457
         updates. Generally you won't need to change this value.
428 458
         Default: ``"row_update"``.
459
+
460
+    .. attribute:: ajax_cell_action_name
461
+
462
+        String that is used for the query parameter key to request AJAX
463
+        updates of cell. Generally you won't need to change this value.
464
+        It is also used for inline edit of the cell.
465
+        Default: ``"cell_update"``.
429 466
     """
430 467
     ajax = False
431 468
     ajax_action_name = "row_update"
469
+    ajax_cell_action_name = "cell_update"
432 470
 
433 471
     def __init__(self, table, datum=None):
434 472
         super(Row, self).__init__()
@@ -466,14 +504,40 @@ class Row(html.HTMLElement):
466 504
                 widget = forms.CheckboxInput(check_test=lambda value: False)
467 505
                 # Convert value to string to avoid accidental type conversion
468 506
                 data = widget.render('object_ids',
469
-                                     unicode(table.get_object_id(datum)))
507
+                                     unicode(table.get_object_id(datum)),
508
+                                     {'class': 'table-row-multi-select'})
509
+                table._data_cache[column][table.get_object_id(datum)] = data
510
+            elif column.auto == "form_field":
511
+                widget = column.form_field
512
+                if issubclass(widget.__class__, forms.Field):
513
+                    widget = widget.widget
514
+
515
+                widget_name = "%s__%s" % \
516
+                    (column.name,
517
+                     unicode(table.get_object_id(datum)))
518
+
519
+                # Create local copy of attributes, so it don't change column
520
+                # class form_field_attributes
521
+                form_field_attributes = {}
522
+                form_field_attributes.update(column.form_field_attributes)
523
+                # Adding id of the input so it pairs with label correctly
524
+                form_field_attributes['id'] = widget_name
525
+
526
+                data = widget.render(widget_name,
527
+                                     column.get_data(datum),
528
+                                     form_field_attributes)
470 529
                 table._data_cache[column][table.get_object_id(datum)] = data
471 530
             elif column.auto == "actions":
472 531
                 data = table.render_row_actions(datum)
473 532
                 table._data_cache[column][table.get_object_id(datum)] = data
474 533
             else:
475 534
                 data = column.get_data(datum)
535
+
476 536
             cell = Cell(datum, data, column, self)
537
+            if cell.inline_edit_available:
538
+                cell.attrs['data-cell-name'] = column.name
539
+                cell.attrs['data-update-url'] = cell.get_ajax_update_url()
540
+
477 541
             cells.append((column.name or column.auto, cell))
478 542
         self.cells = SortedDict(cells)
479 543
 
@@ -483,6 +547,8 @@ class Row(html.HTMLElement):
483 547
             self.attrs['data-update-url'] = self.get_ajax_update_url()
484 548
             self.classes.append("ajax-update")
485 549
 
550
+        self.attrs['data-object-id'] = table.get_object_id(datum)
551
+
486 552
         # Add the row's status class and id to the attributes to be rendered.
487 553
         self.classes.append(self.status_class)
488 554
         id_vals = {"table": self.table.name,
@@ -553,12 +619,22 @@ class Cell(html.HTMLElement):
553 619
         self.column = column
554 620
         self.row = row
555 621
         self.wrap_list = column.wrap_list
622
+        self.inline_edit_available = self.column.update_action is not None
623
+        # initialize the update action if available
624
+        if self.inline_edit_available:
625
+            self.update_action = self.column.update_action()
626
+        self.inline_edit_mod = False
556 627
 
557 628
     def __repr__(self):
558 629
         return '<%s: %s, %s>' % (self.__class__.__name__,
559 630
                                  self.column.name,
560 631
                                  self.row.id)
561 632
 
633
+    @property
634
+    def id(self):
635
+        return ("%s__%s" % (self.column.name,
636
+                unicode(self.row.table.get_object_id(self.datum))))
637
+
562 638
     @property
563 639
     def value(self):
564 640
         """Returns a formatted version of the data for final output.
@@ -631,8 +707,35 @@ class Cell(html.HTMLElement):
631 707
         classes = set(column_class_string.split(" "))
632 708
         if self.column.status:
633 709
             classes.add(self.get_status_class(self.status))
710
+
711
+        if self.inline_edit_available:
712
+            classes.add("inline_edit_available")
713
+
634 714
         return list(classes)
635 715
 
716
+    def get_ajax_update_url(self):
717
+        column = self.column
718
+        table_url = column.table.get_absolute_url()
719
+        params = urlencode({"table": column.table.name,
720
+                            "action": self.row.ajax_cell_action_name,
721
+                            "obj_id": column.table.get_object_id(self.datum),
722
+                            "cell_name": column.name})
723
+        return "%s?%s" % (table_url, params)
724
+
725
+    @property
726
+    def update_allowed(self):
727
+        """Determines whether update of given cell is allowed.
728
+
729
+        Calls allowed action of defined UpdateAction of the Column.
730
+        """
731
+        return self.update_action.allowed(self.column.table.request,
732
+                                          self.datum,
733
+                                          self)
734
+
735
+    def render(self):
736
+        return render_to_string("horizon/common/_data_table_cell.html",
737
+                                {"cell": self})
738
+
636 739
 
637 740
 class DataTableOptions(object):
638 741
     """Contains options for :class:`.DataTable` objects.
@@ -1224,6 +1327,11 @@ class DataTable(object):
1224 1327
                         return HttpResponse(new_row.render())
1225 1328
                     else:
1226 1329
                         return HttpResponse(status=error.status_code)
1330
+            elif new_row.ajax_cell_action_name == action_name:
1331
+                # inline edit of the cell actions
1332
+                return self.inline_edit_handle(request, table_name,
1333
+                                               action_name, obj_id,
1334
+                                               new_row)
1227 1335
 
1228 1336
             preemptive_actions = [action for action in
1229 1337
                                   self.base_actions.values() if action.preempt]
@@ -1235,6 +1343,90 @@ class DataTable(object):
1235 1343
                             return handled
1236 1344
         return None
1237 1345
 
1346
+    def inline_edit_handle(self, request, table_name, action_name, obj_id,
1347
+                           new_row):
1348
+        """Inline edit handler.
1349
+
1350
+        Showing form or handling update by POST of the cell.
1351
+        """
1352
+        try:
1353
+            cell_name = request.GET['cell_name']
1354
+            datum = new_row.get_data(request, obj_id)
1355
+            # TODO(lsmola) extract load cell logic to Cell and load
1356
+            # only 1 cell. This is kind of ugly.
1357
+            if request.GET.get('inline_edit_mod') == "true":
1358
+                new_row.table.columns[cell_name].auto = "form_field"
1359
+                inline_edit_mod = True
1360
+            else:
1361
+                inline_edit_mod = False
1362
+
1363
+            # Load the cell and set the inline_edit_mod.
1364
+            new_row.load_cells(datum)
1365
+            cell = new_row.cells[cell_name]
1366
+            cell.inline_edit_mod = inline_edit_mod
1367
+
1368
+            # If not allowed, neither edit mod or updating is allowed.
1369
+            if not cell.update_allowed:
1370
+                datum_display = (self.get_object_display(datum) or
1371
+                                 _("N/A"))
1372
+                LOG.info('Permission denied to %s: "%s"' %
1373
+                         ("Update Action", datum_display))
1374
+                return HttpResponse(status=401)
1375
+            # If it is post request, we are updating the cell.
1376
+            if request.method == "POST":
1377
+                return self.inline_update_action(request,
1378
+                                                 datum,
1379
+                                                 cell,
1380
+                                                 obj_id,
1381
+                                                 cell_name)
1382
+
1383
+            error = False
1384
+        except Exception:
1385
+            datum = None
1386
+            error = exceptions.handle(request, ignore=True)
1387
+        if request.is_ajax():
1388
+            if not error:
1389
+                return HttpResponse(cell.render())
1390
+            else:
1391
+                return HttpResponse(status=error.status_code)
1392
+
1393
+    def inline_update_action(self, request, datum, cell, obj_id, cell_name):
1394
+        """Handling update by POST of the cell.
1395
+        """
1396
+        new_cell_value = request.POST.get(
1397
+            cell_name + '__' + obj_id, None)
1398
+        if issubclass(cell.column.form_field.__class__,
1399
+                      forms.Field):
1400
+            try:
1401
+                # using Django Form Field to parse the
1402
+                # right value from POST and to validate it
1403
+                new_cell_value = (
1404
+                    cell.column.form_field.clean(
1405
+                        new_cell_value))
1406
+                cell.update_action.action(
1407
+                    self.request, datum, obj_id, cell_name, new_cell_value)
1408
+                response = {
1409
+                    'status': 'updated',
1410
+                    'message': ''
1411
+                }
1412
+                return HttpResponse(
1413
+                    json.dumps(response),
1414
+                    status=200,
1415
+                    content_type="application/json")
1416
+
1417
+            except core_exceptions.ValidationError:
1418
+                # if there is a validation error, I will
1419
+                # return the message to the client
1420
+                exc_type, exc_value, exc_traceback = (
1421
+                    sys.exc_info())
1422
+                response = {
1423
+                    'status': 'validation_error',
1424
+                    'message': ' '.join(exc_value.messages)}
1425
+                return HttpResponse(
1426
+                    json.dumps(response),
1427
+                    status=400,
1428
+                    content_type="application/json")
1429
+
1238 1430
     def maybe_handle(self):
1239 1431
         """Determine whether the request should be handled by any action on
1240 1432
         this table after data has been loaded.

+ 1
- 0
horizon/templates/horizon/_scripts.html View File

@@ -36,6 +36,7 @@
36 36
 <script src='{{ STATIC_URL }}horizon/js/horizon.modals.js' type='text/javascript' charset='utf-8'></script>
37 37
 <script src='{{ STATIC_URL }}horizon/js/horizon.quota.js' type='text/javascript' charset='utf-8'></script>
38 38
 <script src='{{ STATIC_URL }}horizon/js/horizon.tables.js' type='text/javascript' charset='utf-8'></script>
39
+<script src='{{ STATIC_URL }}horizon/js/horizon.tables_inline_edit.js' type='text/javascript' charset='utf-8'></script>
39 40
 <script src='{{ STATIC_URL }}horizon/js/horizon.tabs.js' type='text/javascript' charset='utf-8'></script>
40 41
 <script src='{{ STATIC_URL }}horizon/js/horizon.templates.js' type='text/javascript' charset='utf-8'></script>
41 42
 <script src='{{ STATIC_URL }}horizon/js/horizon.users.js' type='text/javascript' charset='utf-8'></script>

+ 37
- 0
horizon/templates/horizon/common/_data_table_cell.html View File

@@ -0,0 +1,37 @@
1
+{% if cell.inline_edit_mod and cell.update_allowed %}
2
+   <td{{ cell.attr_string|safe }}>
3
+        <div class="table_cell_wrapper">
4
+            <div class="inline-edit-error"> </div>
5
+            <div class="inline-edit-form">
6
+                {{ cell.value }}
7
+                {% if cell.column.form_field.label %}
8
+                    <label class="inline-edit-label" for="{{ cell.id }}">{{ cell.column.form_field.label }}</label>
9
+                {% endif %}
10
+            </div>
11
+            <div class="inline-edit-actions">
12
+                <button class="inline-edit-submit btn btn-primary pull-right"
13
+                     name="action"
14
+                     value="" type="submit">
15
+                </button>
16
+                <button  class="inline-edit-cancel btn secondary cancel"></button>
17
+            </div>
18
+            <div class="inline-edit-status inline-edit-mod"></div>
19
+        </div>
20
+    </td>
21
+{% else %}
22
+    {% if cell.inline_edit_available and cell.update_allowed %}
23
+        <td{{ cell.attr_string|safe }}>
24
+            <div class="table_cell_wrapper">
25
+                <div class="table_cell_data_wrapper">
26
+                    {%if cell.wrap_list %}<ul>{% endif %}{{ cell.value }}{%if cell.wrap_list %}</ul>{% endif %}
27
+                </div>
28
+                <div class="table_cell_action">
29
+                    <button class="ajax-inline-edit btn-edit"></button>
30
+                </div>
31
+                <div class="inline-edit-status"></div>
32
+            </div>
33
+        </td>
34
+    {% else %}
35
+        <td{{ cell.attr_string|safe }}>{{ cell.value }}</td>
36
+    {% endif %}
37
+{% endif %}

+ 5
- 1
horizon/templates/horizon/common/_data_table_row.html View File

@@ -1,3 +1,7 @@
1 1
 <tr{{ row.attr_string|safe }}>
2
-  {% 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 %}
2
+    {% spaceless %}
3
+        {% for cell in row %}
4
+            {% include "horizon/common/_data_table_cell.html" %}
5
+        {% endfor %}
6
+    {% endspaceless %}
3 7
 </tr>

+ 336
- 1
horizon/test/tests/tables.py View File

@@ -15,6 +15,7 @@
15 15
 #    under the License.
16 16
 
17 17
 from django.core.urlresolvers import reverse  # noqa
18
+from django import forms
18 19
 from django import http
19 20
 from django import shortcuts
20 21
 
@@ -145,6 +146,19 @@ class MyFilterAction(tables.FilterAction):
145 146
         return filter(comp, objs)
146 147
 
147 148
 
149
+class MyUpdateAction(tables.UpdateAction):
150
+    def allowed(self, *args):
151
+        return True
152
+
153
+    def update_cell(self, *args):
154
+        pass
155
+
156
+
157
+class MyUpdateActionNotAllowed(MyUpdateAction):
158
+    def allowed(self, *args):
159
+        return False
160
+
161
+
148 162
 def get_name(obj):
149 163
     return "custom %s" % obj.name
150 164
 
@@ -155,7 +169,12 @@ def get_link(obj):
155 169
 
156 170
 class MyTable(tables.DataTable):
157 171
     id = tables.Column('id', hidden=True, sortable=False)
158
-    name = tables.Column(get_name, verbose_name="Verbose Name", sortable=True)
172
+    name = tables.Column(get_name,
173
+                         verbose_name="Verbose Name",
174
+                         sortable=True,
175
+                         form_field=forms.CharField(required=True),
176
+                         form_field_attributes={'class': 'test'},
177
+                         update_action=MyUpdateAction)
159 178
     value = tables.Column('value',
160 179
                           sortable=True,
161 180
                           link='http://example.com/',
@@ -178,6 +197,20 @@ class MyTable(tables.DataTable):
178 197
         row_actions = (MyAction, MyLinkAction, MyBatchAction, MyToggleAction)
179 198
 
180 199
 
200
+class MyTableNotAllowedInlineEdit(MyTable):
201
+    name = tables.Column(get_name,
202
+                         verbose_name="Verbose Name",
203
+                         sortable=True,
204
+                         form_field=forms.CharField(required=True),
205
+                         form_field_attributes={'class': 'test'},
206
+                         update_action=MyUpdateActionNotAllowed)
207
+
208
+    class Meta:
209
+        name = "my_table"
210
+        columns = ('id', 'name', 'value', 'optional', 'status')
211
+        row_class = MyRow
212
+
213
+
181 214
 class NoActionsTable(tables.DataTable):
182 215
     id = tables.Column('id')
183 216
 
@@ -238,6 +271,11 @@ class DataTableTests(test.TestCase):
238 271
         self.assertEqual(actions.auto, "actions")
239 272
         self.assertEqual(actions.get_final_attrs().get('class', ""),
240 273
                          "actions_column")
274
+        # In-line edit action on column.
275
+        name_column = self.table.columns['name']
276
+        self.assertEqual(name_column.update_action, MyUpdateAction)
277
+        self.assertEqual(name_column.form_field.__class__, forms.CharField)
278
+        self.assertEqual(name_column.form_field_attributes, {'class': 'test'})
241 279
 
242 280
     def test_table_force_no_multiselect(self):
243 281
         class TempTable(MyTable):
@@ -263,6 +301,22 @@ class DataTableTests(test.TestCase):
263 301
                                  ['<Column: multi_select>',
264 302
                                   '<Column: id>'])
265 303
 
304
+    def test_table_natural_no_inline_editing(self):
305
+        class TempTable(MyTable):
306
+            name = tables.Column(get_name,
307
+                                 verbose_name="Verbose Name",
308
+                                 sortable=True)
309
+
310
+            class Meta:
311
+                name = "my_table"
312
+                columns = ('id', 'name', 'value', 'optional', 'status')
313
+
314
+        self.table = TempTable(self.request, TEST_DATA_2)
315
+        name_column = self.table.columns['name']
316
+        self.assertEqual(name_column.update_action, None)
317
+        self.assertEqual(name_column.form_field, None)
318
+        self.assertEqual(name_column.form_field_attributes, {})
319
+
266 320
     def test_table_natural_no_actions_column(self):
267 321
         class TempTable(MyTable):
268 322
             class Meta:
@@ -445,6 +499,172 @@ class DataTableTests(test.TestCase):
445 499
         resp = http.HttpResponse(table_actions)
446 500
         self.assertContains(resp, "table_search", 0)
447 501
 
502
+    def test_inline_edit_available_cell_rendering(self):
503
+        self.table = MyTable(self.request, TEST_DATA_2)
504
+        row = self.table.get_rows()[0]
505
+        name_cell = row.cells['name']
506
+
507
+        # Check if in-line edit is available in the cell,
508
+        # but is not in inline_edit_mod.
509
+        self.assertEqual(name_cell.inline_edit_available,
510
+                         True)
511
+        self.assertEqual(name_cell.inline_edit_mod,
512
+                         False)
513
+
514
+        # Check if is cell is rendered correctly.
515
+        name_cell_rendered = name_cell.render()
516
+        resp = http.HttpResponse(name_cell_rendered)
517
+
518
+        self.assertContains(resp, '<td', 1)
519
+        self.assertContains(resp, 'inline_edit_available', 1)
520
+        self.assertContains(resp,
521
+                            'data-update-url="?action=cell_update&amp;'
522
+                            'table=my_table&amp;cell_name=name&amp;obj_id=1"',
523
+                            1)
524
+        self.assertContains(resp, 'table_cell_wrapper', 1)
525
+        self.assertContains(resp, 'table_cell_data_wrapper', 1)
526
+        self.assertContains(resp, 'table_cell_action', 1)
527
+        self.assertContains(resp, 'ajax-inline-edit', 1)
528
+
529
+    def test_inline_edit_available_not_allowed_cell_rendering(self):
530
+        self.table = MyTableNotAllowedInlineEdit(self.request, TEST_DATA_2)
531
+
532
+        row = self.table.get_rows()[0]
533
+        name_cell = row.cells['name']
534
+
535
+        # Check if in-line edit is available in the cell,
536
+        # but is not in inline_edit_mod.
537
+        self.assertEqual(name_cell.inline_edit_available,
538
+                         True)
539
+        self.assertEqual(name_cell.inline_edit_mod,
540
+                         False)
541
+
542
+        # Check if is cell is rendered correctly.
543
+        name_cell_rendered = name_cell.render()
544
+        resp = http.HttpResponse(name_cell_rendered)
545
+
546
+        self.assertContains(resp, '<td', 1)
547
+        self.assertContains(resp, 'inline_edit_available', 1)
548
+        self.assertContains(resp,
549
+                            'data-update-url="?action=cell_update&amp;'
550
+                            'table=my_table&amp;cell_name=name&amp;obj_id=1"',
551
+                            1)
552
+        self.assertContains(resp, 'table_cell_wrapper', 0)
553
+        self.assertContains(resp, 'table_cell_data_wrapper', 0)
554
+        self.assertContains(resp, 'table_cell_action', 0)
555
+        self.assertContains(resp, 'ajax-inline-edit', 0)
556
+
557
+    def test_inline_edit_mod_cell_rendering(self):
558
+        self.table = MyTable(self.request, TEST_DATA_2)
559
+        name_col = self.table.columns['name']
560
+        name_col.auto = "form_field"
561
+
562
+        row = self.table.get_rows()[0]
563
+        name_cell = row.cells['name']
564
+        name_cell.inline_edit_mod = True
565
+
566
+        # Check if in-line edit is available in the cell,
567
+        # and is in inline_edit_mod, also column auto must be
568
+        # set as form_field.
569
+        self.assertEqual(name_cell.inline_edit_available,
570
+                         True)
571
+        self.assertEqual(name_cell.inline_edit_mod,
572
+                         True)
573
+        self.assertEqual(name_col.auto,
574
+                         'form_field')
575
+
576
+        # Check if is cell is rendered correctly.
577
+        name_cell_rendered = name_cell.render()
578
+        resp = http.HttpResponse(name_cell_rendered)
579
+
580
+        self.assertContains(resp,
581
+                            '<input class="test" id="name__1" name="name__1"'
582
+                            ' type="text" value="custom object_1" />',
583
+                            count=1, html=True)
584
+
585
+        self.assertContains(resp, '<td', 1)
586
+        self.assertContains(resp, 'inline_edit_available', 1)
587
+        self.assertContains(resp,
588
+                            'data-update-url="?action=cell_update&amp;'
589
+                            'table=my_table&amp;cell_name=name&amp;obj_id=1"',
590
+                            1)
591
+        self.assertContains(resp, 'table_cell_wrapper', 1)
592
+        self.assertContains(resp, 'inline-edit-error', 1)
593
+        self.assertContains(resp, 'inline-edit-form', 1)
594
+        self.assertContains(resp, 'inline-edit-actions', 1)
595
+        self.assertContains(resp, 'inline-edit-submit', 1)
596
+        self.assertContains(resp, 'inline-edit-cancel', 1)
597
+
598
+    def test_inline_edit_mod_checkbox_with_label(self):
599
+        class TempTable(MyTable):
600
+            name = tables.Column(get_name,
601
+                                 verbose_name="Verbose Name",
602
+                                 sortable=True,
603
+                                 form_field=forms.BooleanField(
604
+                                     required=True,
605
+                                     label="Verbose Name"),
606
+                                 form_field_attributes={'class': 'test'},
607
+                                 update_action=MyUpdateAction)
608
+
609
+            class Meta:
610
+                name = "my_table"
611
+                columns = ('id', 'name', 'value', 'optional', 'status')
612
+
613
+        self.table = TempTable(self.request, TEST_DATA_2)
614
+        name_col = self.table.columns['name']
615
+        name_col.auto = "form_field"
616
+
617
+        row = self.table.get_rows()[0]
618
+        name_cell = row.cells['name']
619
+        name_cell.inline_edit_mod = True
620
+
621
+        # Check if is cell is rendered correctly.
622
+        name_cell_rendered = name_cell.render()
623
+        resp = http.HttpResponse(name_cell_rendered)
624
+
625
+        self.assertContains(resp,
626
+                            '<input checked="checked" class="test" '
627
+                            'id="name__1" name="name__1" type="checkbox" '
628
+                            'value="custom object_1" />',
629
+                            count=1, html=True)
630
+        self.assertContains(resp,
631
+                            '<label class="inline-edit-label" for="name__1">'
632
+                            'Verbose Name</label>',
633
+                            count=1, html=True)
634
+
635
+    def test_inline_edit_mod_textarea(self):
636
+        class TempTable(MyTable):
637
+            name = tables.Column(get_name,
638
+                                 verbose_name="Verbose Name",
639
+                                 sortable=True,
640
+                                 form_field=forms.CharField(
641
+                                     widget=forms.Textarea(),
642
+                                     required=False),
643
+                                 form_field_attributes={'class': 'test'},
644
+                                 update_action=MyUpdateAction)
645
+
646
+            class Meta:
647
+                name = "my_table"
648
+                columns = ('id', 'name', 'value', 'optional', 'status')
649
+
650
+        self.table = TempTable(self.request, TEST_DATA_2)
651
+        name_col = self.table.columns['name']
652
+        name_col.auto = "form_field"
653
+
654
+        row = self.table.get_rows()[0]
655
+        name_cell = row.cells['name']
656
+        name_cell.inline_edit_mod = True
657
+
658
+        # Check if is cell is rendered correctly.
659
+        name_cell_rendered = name_cell.render()
660
+        resp = http.HttpResponse(name_cell_rendered)
661
+
662
+        self.assertContains(resp,
663
+                            '<textarea class="test" cols="40" id="name__1" '
664
+                            'name="name__1" rows="10">\r\ncustom object_1'
665
+                            '</textarea>',
666
+                            count=1, html=True)
667
+
448 668
     def test_table_actions(self):
449 669
         # Single object action
450 670
         action_string = "my_table__delete__1"
@@ -603,6 +823,121 @@ class DataTableTests(test.TestCase):
603 823
         self.assertEqual(unicode(row_actions[0].verbose_name), "Delete Me")
604 824
         self.assertEqual(unicode(row_actions[1].verbose_name), "Log In")
605 825
 
826
+    def test_inline_edit_update_action_get_non_ajax(self):
827
+        # Non ajax inline edit request should return None.
828
+        url = ('/my_url/?action=cell_update'
829
+               '&table=my_table&cell_name=name&obj_id=1')
830
+        req = self.factory.get(url, {})
831
+        self.table = MyTable(req, TEST_DATA_2)
832
+        handled = self.table.maybe_preempt()
833
+        # Checking the response header.
834
+        self.assertEqual(handled, None)
835
+
836
+    def test_inline_edit_update_action_get(self):
837
+        # Get request should return td field with data.
838
+        url = ('/my_url/?action=cell_update'
839
+               '&table=my_table&cell_name=name&obj_id=1')
840
+        req = self.factory.get(url, {},
841
+                               HTTP_X_REQUESTED_WITH='XMLHttpRequest')
842
+        self.table = MyTable(req, TEST_DATA_2)
843
+        handled = self.table.maybe_preempt()
844
+        # Checking the response header.
845
+        self.assertEqual(handled.status_code, 200)
846
+        # Checking the response content.
847
+        resp = handled
848
+        self.assertContains(resp, '<td', 1)
849
+        self.assertContains(resp, 'inline_edit_available', 1)
850
+        self.assertContains(
851
+            resp,
852
+            'data-update-url="/my_url/?action=cell_update&amp;'
853
+            'table=my_table&amp;cell_name=name&amp;obj_id=1"',
854
+            1)
855
+        self.assertContains(resp, 'table_cell_wrapper', 1)
856
+        self.assertContains(resp, 'table_cell_data_wrapper', 1)
857
+        self.assertContains(resp, 'table_cell_action', 1)
858
+        self.assertContains(resp, 'ajax-inline-edit', 1)
859
+
860
+    def test_inline_edit_update_action_get_not_allowed(self):
861
+        # Name column has required validation, sending blank
862
+        # will return error.
863
+        url = ('/my_url/?action=cell_update'
864
+               '&table=my_table&cell_name=name&obj_id=1')
865
+        req = self.factory.post(url, {})
866
+        self.table = MyTableNotAllowedInlineEdit(req, TEST_DATA_2)
867
+        handled = self.table.maybe_preempt()
868
+        # Checking the response header.
869
+        self.assertEqual(handled.status_code, 401)
870
+
871
+    def test_inline_edit_update_action_get_inline_edit_mod(self):
872
+        # Get request in inline_edit_mode should return td with form field.
873
+        url = ('/my_url/?inline_edit_mod=true&action=cell_update'
874
+               '&table=my_table&cell_name=name&obj_id=1')
875
+        req = self.factory.get(url, {},
876
+                               HTTP_X_REQUESTED_WITH='XMLHttpRequest')
877
+        self.table = MyTable(req, TEST_DATA_2)
878
+        handled = self.table.maybe_preempt()
879
+        # Checking the response header.
880
+        self.assertEqual(handled.status_code, 200)
881
+        # Checking the response content.
882
+        resp = handled
883
+        self.assertContains(resp,
884
+                            '<input class="test" id="name__1" name="name__1"'
885
+                            ' type="text" value="custom object_1" />',
886
+                            count=1, html=True)
887
+
888
+        self.assertContains(resp, '<td', 1)
889
+        self.assertContains(resp, 'inline_edit_available', 1)
890
+        self.assertContains(
891
+            resp,
892
+            'data-update-url="/my_url/?action=cell_update&amp;'
893
+            'table=my_table&amp;cell_name=name&amp;obj_id=1"',
894
+            1)
895
+        self.assertContains(resp, 'table_cell_wrapper', 1)
896
+        self.assertContains(resp, 'inline-edit-error', 1)
897
+        self.assertContains(resp, 'inline-edit-form', 1)
898
+        self.assertContains(resp, 'inline-edit-actions', 1)
899
+        self.assertContains(resp, '<button', 2)
900
+        self.assertContains(resp, 'inline-edit-submit', 1)
901
+        self.assertContains(resp, 'inline-edit-cancel', 1)
902
+
903
+    def test_inline_edit_update_action_post(self):
904
+        # Post request should invoke the cell update table action.
905
+        url = ('/my_url/?action=cell_update'
906
+               '&table=my_table&cell_name=name&obj_id=1')
907
+        req = self.factory.post(url, {'name__1': 'test_name'})
908
+        self.table = MyTable(req, TEST_DATA_2)
909
+        # checking the response header
910
+        handled = self.table.maybe_preempt()
911
+        self.assertEqual(handled.status_code, 200)
912
+
913
+    def test_inline_edit_update_action_post_not_allowed(self):
914
+        # Post request should invoke the cell update table action.
915
+        url = ('/my_url/?action=cell_update'
916
+               '&table=my_table&cell_name=name&obj_id=1')
917
+        req = self.factory.post(url, {'name__1': 'test_name'})
918
+        self.table = MyTableNotAllowedInlineEdit(req, TEST_DATA_2)
919
+        # checking the response header
920
+        handled = self.table.maybe_preempt()
921
+        self.assertEqual(handled.status_code, 401)
922
+
923
+    def test_inline_edit_update_action_post_validation_error(self):
924
+        # Name column has required validation, sending blank
925
+        # will return error.
926
+        url = ('/my_url/?action=cell_update'
927
+               '&table=my_table&cell_name=name&obj_id=1')
928
+        req = self.factory.post(url, {})
929
+        self.table = MyTable(req, TEST_DATA_2)
930
+        handled = self.table.maybe_preempt()
931
+        # Checking the response header.
932
+        self.assertEqual(handled.status_code, 400)
933
+        self.assertEqual(handled._headers['content-type'],
934
+                         ('Content-Type', 'application/json'))
935
+        # Checking the response content.
936
+        resp = handled
937
+        self.assertContains(resp,
938
+                            '"message": "This field is required."',
939
+                            count=1, status_code=400)
940
+
606 941
     def test_column_uniqueness(self):
607 942
         table1 = MyTable(self.request)
608 943
         table2 = MyTable(self.request)

+ 56
- 3
openstack_dashboard/dashboards/admin/projects/tables.py View File

@@ -10,11 +10,15 @@
10 10
 # License for the specific language governing permissions and limitations
11 11
 # under the License.
12 12
 
13
+from django.core.exceptions import ValidationError  # noqa
13 14
 from django.core.urlresolvers import reverse  # noqa
14 15
 from django.utils.http import urlencode  # noqa
15 16
 from django.utils.translation import ugettext_lazy as _  # noqa
16 17
 
18
+from horizon import exceptions
19
+from horizon import forms
17 20
 from horizon import tables
21
+from keystoneclient.exceptions import Conflict  # noqa
18 22
 
19 23
 from openstack_dashboard import api
20 24
 from openstack_dashboard.api import keystone
@@ -121,16 +125,65 @@ class TenantFilterAction(tables.FilterAction):
121 125
         return filter(comp, tenants)
122 126
 
123 127
 
128
+class UpdateRow(tables.Row):
129
+    ajax = True
130
+
131
+    def get_data(self, request, project_id):
132
+        project_info = api.keystone.tenant_get(request, project_id,
133
+                                               admin=True)
134
+        return project_info
135
+
136
+
137
+class UpdateCell(tables.UpdateAction):
138
+    def allowed(self, request, project, cell):
139
+        return api.keystone.keystone_can_edit_project()
140
+
141
+    def update_cell(self, request, datum, project_id,
142
+                    cell_name, new_cell_value):
143
+        # inline update project info
144
+        try:
145
+            project_obj = datum
146
+            # updating changed value by new value
147
+            setattr(project_obj, cell_name, new_cell_value)
148
+            api.keystone.tenant_update(
149
+                request,
150
+                project_id,
151
+                name=project_obj.name,
152
+                description=project_obj.description,
153
+                enabled=project_obj.enabled)
154
+
155
+        except Conflict:
156
+            # Returning a nice error message about name conflict. The message
157
+            # from exception is not that clear for the users.
158
+            message = _("This name is already taken.")
159
+            raise ValidationError(message)
160
+        except Exception:
161
+            exceptions.handle(request, ignore=True)
162
+            return False
163
+        return True
164
+
165
+
124 166
 class TenantsTable(tables.DataTable):
125
-    name = tables.Column('name', verbose_name=_('Name'))
167
+    name = tables.Column('name', verbose_name=_('Name'),
168
+                         form_field=forms.CharField(required=True),
169
+                         update_action=UpdateCell)
126 170
     description = tables.Column(lambda obj: getattr(obj, 'description', None),
127
-                                verbose_name=_('Description'))
171
+                                verbose_name=_('Description'),
172
+                                form_field=forms.CharField(
173
+                                    widget=forms.Textarea(),
174
+                                    required=False),
175
+                                update_action=UpdateCell)
128 176
     id = tables.Column('id', verbose_name=_('Project ID'))
129
-    enabled = tables.Column('enabled', verbose_name=_('Enabled'), status=True)
177
+    enabled = tables.Column('enabled', verbose_name=_('Enabled'), status=True,
178
+                            form_field=forms.BooleanField(
179
+                                label=_('Enabled'),
180
+                                required=False),
181
+                            update_action=UpdateCell)
130 182
 
131 183
     class Meta:
132 184
         name = "tenants"
133 185
         verbose_name = _("Projects")
186
+        row_class = UpdateRow
134 187
         row_actions = (ViewMembersLink, ViewGroupsLink, UpdateProject,
135 188
                        UsageLink, ModifyQuotas, DeleteTenantsAction)
136 189
         table_actions = (TenantFilterAction, CreateProject,

+ 139
- 1
openstack_dashboard/dashboards/admin/projects/tests.py View File

@@ -14,22 +14,26 @@
14 14
 #    License for the specific language governing permissions and limitations
15 15
 #    under the License.
16 16
 
17
+import copy
17 18
 import logging
18 19
 
19 20
 from django.core.urlresolvers import reverse  # noqa
20 21
 from django import http
21 22
 from django.test.utils import override_settings  # noqa
22 23
 
24
+from mox import IgnoreArg  # noqa
23 25
 from mox import IsA  # noqa
24 26
 
25 27
 from horizon import exceptions
26 28
 from horizon.workflows import views
27 29
 
28 30
 from openstack_dashboard import api
31
+from openstack_dashboard.dashboards.admin.projects import workflows
29 32
 from openstack_dashboard.test import helpers as test
30 33
 from openstack_dashboard.usage import quotas
31 34
 
32
-from openstack_dashboard.dashboards.admin.projects import workflows
35
+from selenium.webdriver import ActionChains  # noqa
36
+from socket import timeout as socket_timeout  # noqa
33 37
 
34 38
 
35 39
 INDEX_URL = reverse('horizon:admin:projects:index')
@@ -1440,3 +1444,137 @@ class UpdateProjectWorkflowTests(test.BaseAdminViewTests):
1440 1444
                 self.client.get(url)
1441 1445
         finally:
1442 1446
             logging.disable(logging.NOTSET)
1447
+
1448
+
1449
+class SeleniumTests(test.SeleniumAdminTestCase):
1450
+    @test.create_stubs(
1451
+        {api.keystone: ('tenant_list', 'tenant_get', 'tenant_update')})
1452
+    def test_inline_editing_update(self):
1453
+        # Tenant List
1454
+        api.keystone.tenant_list(IgnoreArg(),
1455
+                                 domain=None,
1456
+                                 marker=None,
1457
+                                 paginate=True) \
1458
+            .AndReturn([self.tenants.list(), False])
1459
+        # Edit mod
1460
+        api.keystone.tenant_get(IgnoreArg(),
1461
+                                u'1',
1462
+                                admin=True) \
1463
+            .AndReturn(self.tenants.list()[0])
1464
+        # Update - requires get and update
1465
+        api.keystone.tenant_get(IgnoreArg(),
1466
+                                u'1',
1467
+                                admin=True) \
1468
+            .AndReturn(self.tenants.list()[0])
1469
+        api.keystone.tenant_update(
1470
+            IgnoreArg(),
1471
+            u'1',
1472
+            description='a test tenant.',
1473
+            enabled=True,
1474
+            name=u'Changed test_tenant')
1475
+        # Refreshing cell with changed name
1476
+        changed_tenant = copy.copy(self.tenants.list()[0])
1477
+        changed_tenant.name = u'Changed test_tenant'
1478
+        api.keystone.tenant_get(IgnoreArg(),
1479
+                                u'1',
1480
+                                admin=True) \
1481
+            .AndReturn(changed_tenant)
1482
+
1483
+        self.mox.ReplayAll()
1484
+
1485
+        self.selenium.get("%s%s" % (self.live_server_url, INDEX_URL))
1486
+
1487
+        # Check the presence of the important elements
1488
+        td_element = self.selenium.find_element_by_xpath(
1489
+            "//td[@data-update-url='/admin/projects/?action=cell_update"
1490
+            "&table=tenants&cell_name=name&obj_id=1']")
1491
+        cell_wrapper = td_element.find_element_by_class_name(
1492
+            'table_cell_wrapper')
1493
+        edit_button_wrapper = td_element.find_element_by_class_name(
1494
+            'table_cell_action')
1495
+        edit_button = edit_button_wrapper.find_element_by_tag_name('button')
1496
+        # Hovering over td and clicking on edit button
1497
+        action_chains = ActionChains(self.selenium)
1498
+        action_chains.move_to_element(cell_wrapper).click(edit_button)
1499
+        action_chains.perform()
1500
+        # Waiting for the AJAX response for switching to editing mod
1501
+        wait = self.ui.WebDriverWait(self.selenium, 10,
1502
+                                     ignored_exceptions=[socket_timeout])
1503
+        wait.until(lambda x: self.selenium.find_element_by_name("name__1"))
1504
+        # Changing project name in cell form
1505
+        td_element = self.selenium.find_element_by_xpath(
1506
+            "//td[@data-update-url='/admin/projects/?action=cell_update"
1507
+            "&table=tenants&cell_name=name&obj_id=1']")
1508
+        td_element.find_element_by_tag_name('input').send_keys("Changed ")
1509
+        # Saving new project name by AJAX
1510
+        td_element.find_element_by_class_name('inline-edit-submit').click()
1511
+        # Waiting for the AJAX response of cell refresh
1512
+        wait = self.ui.WebDriverWait(self.selenium, 10,
1513
+                                     ignored_exceptions=[socket_timeout])
1514
+        wait.until(lambda x: self.selenium.find_element_by_xpath(
1515
+            "//td[@data-update-url='/admin/projects/?action=cell_update"
1516
+            "&table=tenants&cell_name=name&obj_id=1']"
1517
+            "/div[@class='table_cell_wrapper']"
1518
+            "/div[@class='table_cell_data_wrapper']"))
1519
+        # Checking new project name after cell refresh
1520
+        data_wrapper = self.selenium.find_element_by_xpath(
1521
+            "//td[@data-update-url='/admin/projects/?action=cell_update"
1522
+            "&table=tenants&cell_name=name&obj_id=1']"
1523
+            "/div[@class='table_cell_wrapper']"
1524
+            "/div[@class='table_cell_data_wrapper']")
1525
+        self.assertTrue(data_wrapper.text == u'Changed test_tenant',
1526
+                        "Error: saved tenant name is expected to be "
1527
+                        "'Changed test_tenant'")
1528
+
1529
+    @test.create_stubs(
1530
+        {api.keystone: ('tenant_list', 'tenant_get')})
1531
+    def test_inline_editing_cancel(self):
1532
+        # Tenant List
1533
+        api.keystone.tenant_list(IgnoreArg(),
1534
+                                 domain=None,
1535
+                                 marker=None,
1536
+                                 paginate=True) \
1537
+            .AndReturn([self.tenants.list(), False])
1538
+        # Edit mod
1539
+        api.keystone.tenant_get(IgnoreArg(),
1540
+                                u'1',
1541
+                                admin=True) \
1542
+            .AndReturn(self.tenants.list()[0])
1543
+        # Cancel edit mod is without the request
1544
+
1545
+        self.mox.ReplayAll()
1546
+
1547
+        self.selenium.get("%s%s" % (self.live_server_url, INDEX_URL))
1548
+
1549
+        # Check the presence of the important elements
1550
+        td_element = self.selenium.find_element_by_xpath(
1551
+            "//td[@data-update-url='/admin/projects/?action=cell_update"
1552
+            "&table=tenants&cell_name=name&obj_id=1']")
1553
+        cell_wrapper = td_element.find_element_by_class_name(
1554
+            'table_cell_wrapper')
1555
+        edit_button_wrapper = td_element.find_element_by_class_name(
1556
+            'table_cell_action')
1557
+        edit_button = edit_button_wrapper.find_element_by_tag_name('button')
1558
+        # Hovering over td and clicking on edit
1559
+        action_chains = ActionChains(self.selenium)
1560
+        action_chains.move_to_element(cell_wrapper).click(edit_button)
1561
+        action_chains.perform()
1562
+        # Waiting for the AJAX response for switching to editing mod
1563
+        wait = self.ui.WebDriverWait(self.selenium, 10,
1564
+                                     ignored_exceptions=[socket_timeout])
1565
+        wait.until(lambda x: self.selenium.find_element_by_name("name__1"))
1566
+        # Click on cancel button
1567
+        td_element = self.selenium.find_element_by_xpath(
1568
+            "//td[@data-update-url='/admin/projects/?action=cell_update"
1569
+            "&table=tenants&cell_name=name&obj_id=1']")
1570
+        td_element.find_element_by_class_name('inline-edit-cancel').click()
1571
+        # Cancel is via javascript, so it should be immediate
1572
+        # Checking that tenant name is not changed
1573
+        data_wrapper = self.selenium.find_element_by_xpath(
1574
+            "//td[@data-update-url='/admin/projects/?action=cell_update"
1575
+            "&table=tenants&cell_name=name&obj_id=1']"
1576
+            "/div[@class='table_cell_wrapper']"
1577
+            "/div[@class='table_cell_data_wrapper']")
1578
+        self.assertTrue(data_wrapper.text == u'test_tenant',
1579
+                        "Error: saved tenant name is expected to be "
1580
+                        "'test_tenant'")

BIN
openstack_dashboard/static/dashboard/img/spinner.gif View File


+ 195
- 0
openstack_dashboard/static/dashboard/less/horizon.less View File

@@ -607,7 +607,202 @@ table form {
607 607
     button.btn-delete, button.btn-terminate {
608 608
       .btn-icon-danger(-451px, 5px);
609 609
     }
610
+}
611
+
612
+td.no-transition {
613
+  -webkit-transition: none !important;
614
+  -moz-transition: none !important;
615
+  -o-transition: none !important;
616
+  -ms-transition: none !important;
617
+  transition: none !important;
618
+}
619
+
620
+td.success {
621
+  background-color: #dff0d8 !important;
622
+}
623
+td.loading {
624
+  background-color: #e6e6e6 !important;
625
+}
626
+
627
+.btn-icon-inline_edit(@x, @y, @top: 1px, @left: 5px, @icons: "/static/bootstrap/img/glyphicons-halflings.png") {
628
+    padding: 9px 12px 9px 12px;
629
+    position: relative;
630
+    border-radius: 0px;
631
+
632
+    &:before {
633
+        display: inline-block;
634
+        content: "";
635
+        width: 18px;
636
+        height: 20px;
637
+        margin-top: 0px;
638
+        *margin-right: .3em;
639
+        line-height: 14px;
640
+        background-image: url(@icons);
641
+        background-position: @x @y;
642
+        background-repeat: no-repeat;
643
+        position: absolute;
644
+        top: @top;
645
+        left: @left;
646
+    }
647
+}
648
+
649
+td.inline_edit_available  {
650
+  div.table_cell_wrapper {
651
+    .table_cell_action {
652
+      button.ajax-inline-edit {
653
+        .btn-icon-inline_edit(0, -72px, 2px, 4px);
654
+
655
+        padding: 10px 10px 10px 10px;
656
+        position: relative;
657
+        display: block;
658
+        background: none;
659
+        border: 0 none;
660
+      }
661
+    }
662
+  }
663
+}
664
+
665
+ .btn-icon-inline-actions(@x, @y, @top: 1px, @left: 5px, @icons: "/static/bootstrap/img/glyphicons-halflings.png") {
666
+    padding: 9px 12px 9px 12px;
667
+    position: relative;
668
+    border-radius: 0px;
669
+
670
+    &:before {
671
+        display: inline-block;
672
+        content: "";
673
+        width: 18px;
674
+        height: 20px;
675
+        margin-top: 0px;
676
+        *margin-right: .3em;
677
+        line-height: 14px;
678
+        background-image: url(@icons);
679
+        background-position: @x @y;
680
+        background-repeat: no-repeat;
681
+        position: absolute;
682
+        top: @top;
683
+        left: @left;
684
+    }
685
+}
686
+
687
+.status-icon(@x, @y, @top: 1px, @left: 5px, @icons: "/static/bootstrap/img/glyphicons-halflings.png") {
688
+  padding: 9px 12px 9px 12px;
689
+  position: relative;
690
+  border-radius: 0px;
610 691
 
692
+  &:before {
693
+      display: inline-block;
694
+      content: "";
695
+      width: 20px;
696
+      height: 20px;
697
+      margin-top: 0px;
698
+      *margin-right: .3em;
699
+      line-height: 14px;
700
+      background-image: url(@icons);
701
+      background-position: @x @y;
702
+      background-repeat: no-repeat;
703
+      position: absolute;
704
+      top: @top;
705
+      left: @left;
706
+  }
707
+}
708
+
709
+div.table_cell_wrapper {
710
+   min-height: 18px;
711
+   position: relative;
712
+
713
+  .inline-edit-label {
714
+    display: inline;
715
+  }
716
+
717
+  .inline-edit-form {
718
+    float: left;
719
+  }
720
+
721
+  .inline-edit-actions, .table_cell_action {
722
+    float: right;
723
+    width: 20px;
724
+    margin: 0;
725
+
726
+    button.inline-edit-cancel {
727
+      float: right;
728
+      .btn-icon-inline-actions(-312px, 0);
729
+    }
730
+
731
+    button.inline-edit-submit {
732
+      .btn-icon-inline-actions(-288px, 0, 1px, 5px, "/static/bootstrap/img/glyphicons-halflings-white.png");
733
+    }
734
+    button.ajax-inline-edit {
735
+      .btn-icon-inline-actions(0, -72px, 2px, 4px);
736
+
737
+      padding: 10px 10px 10px 10px;
738
+      position: relative;
739
+      display: none;
740
+      background: none;
741
+      border: 0 none;
742
+    }
743
+  }
744
+
745
+  .table_cell_action {
746
+    width: auto;
747
+
748
+    margin: auto 0px 0px 0px;
749
+    display: none;
750
+    position: absolute;
751
+    top: -3px;
752
+    right: 0px;
753
+    z-index: 99;
754
+  }
755
+
756
+  .table_cell_action.hovered {
757
+    background-image: -webkit-linear-gradient(top, #ffffff, #e6e6e6);
758
+    border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);
759
+    border: 1px solid #ccc;
760
+    border-bottom-color: #bbb;
761
+    border-radius: 4px;
762
+  }
763
+
764
+  .inline-edit-error {
765
+    .error {
766
+      .status-icon(-144px, -120px, 0px, 0px);
767
+      position: absolute;
768
+      width: 18px;
769
+      height: 20px;
770
+
771
+      top: 20px;
772
+      left: 2px;
773
+      padding: 0;
774
+    }
775
+  }
776
+  .inline-edit-status {
777
+    .success {
778
+      .status-icon(-288px, 0px, 0px, 0px);
779
+      padding: 0;
780
+      position:absolute;
781
+      top: 2px;
782
+      right: 18px;
783
+
784
+      width: 18px;
785
+      height: 20px;
786
+      z-index: 100;
787
+    }
788
+    .loading {
789
+      .status-icon(0px, 0px, 0px, 0px, '/static/dashboard/img/spinner.gif');
790
+      padding: 0;
791
+      position:absolute;
792
+      top: 0px;
793
+      right: 24px;
794
+
795
+      width: 18px;
796
+      height: 20px;
797
+      z-index: 100;
798
+    }
799
+  }
800
+  .inline-edit-status.inline-edit-mod {
801
+    .loading {
802
+      top: 15px;
803
+      right: 34px;
804
+    }
805
+  }
611 806
 }
612 807
 
613 808
 .table_header .table_actions {

Loading…
Cancel
Save