Follow-up to: #80855: Add element #type table and merge tableselect/tabledrag into it

Problem

  • Other modules can easily append a column to a table, but injecting a column in between existing requires complex futzing with array indexes, since theme_table() is unaware of the render element structure; quoting @quicksketch:

    it doesn't look like this easily allows modules that form_alter() a table to easily add an additional column (other than at the end of the table), since the order of the table is based on the declaration order, rather than the order of the header or some weight system.

Goal

  • Make it easy to inject table columns into the middle of an existing table.
CommentFileSizeAuthor
#5 drupal8.table-column-sort.5.patch2.05 KBsun

Comments

sun’s picture

Assigned: Unassigned » quicksketch
sun’s picture

Issue tags: +API change

Conversion process has started on #1876712: [meta] Convert all tables in core to new #type 'table'

#1814916: Convert menus into entities, or more specifically, EntityListController overrides, could use this facility, because they currently have to override the entire buildHeader() and buildRow() methods, even if they just want to inject a single column.

There are multiple ways to approach this:

  1. #weight on header/body columns.

    All implementations would have to define a #weight for each column. And the defined weights have to leave sufficient room for other code/modules to inject additional columns in between existing; i.e.:

    #type table definition:
    
    Column:  Title  Description         Operations
    Weight:  10     20                  30
    
    mymodule_whatever_alter():
    
    Column:                      Roles
    Weight:                      25
    

    The primary problems with that are:

    Columns do not have a #weight by default, or to be more concise, #weight defaults to 0. Thus, if you inject a new column with #weight 25, then it comes last, which is not what you intended.

    We'd have to automatically inject #weight values for all columns that do not have a #weight yet, and give them sensible #weight values; e.g., starting from 0 or 10, in intervals of +10. We'd probably want to inject the additional #tableselect column with -100, and the additional 'weight' column for #tabledrag with a weight of 990, and we'd have to give the 'operations' column a weight of 1000 so it comes last.

    Furthermore, this would have to happen in retroactively, since hook_form_alter() runs before any kind of processing; i.e., directly after the table has been defined as form/render structure.

    Overall, this idea/approach doesn't sound very reliable/feasible.

  2. Sort all body columns according to the column definition in #header.

    Essentially, we'd introduce a hard binding between the #header definition and the element_children() of a table.

    Requirements:

    • Columns in #header have to be keyed; i.e.:
      $form['table'] = array(
        '#type' => 'table',
        '#header' => array(
          'label' => t('Title'),
          'description' => t('Description'),
          'operations' => t('Operations'),
        ),
      );
      
    • Columns in the body (element_children()) have to use the identical keys; i.e.:
      foreach ($thingies as $id => $thing) {
        // Keys must match #header.
        $form['table'][$id]['label'] = array(...);
        $form['table'][$id]['description'] = array(...);
        $form['table'][$id]['operations'] = array(...);
      

    To inject a new column, you 1) simply add the column as child element to each row and 2) inject the column into the desired position in #header. To reposition a column, you change the #header definition only.

    Injecting or repositioning a column within #header will still require futzing with array keys, but it would be a one-time operation for #header only, not for every single table row. (That is, unless we'd mix the above #weight concept into #header, but I'm less convinced of #weight in the first place.)

    The #pre_render of #type table would need some more complex processing to sort all body columns by the order in which the keys appear in #header. I don't know what we'd do about keys that don't match, but we could simply add them last.

    This approach sounds more feasible. However, it would be an API change (for #header).

Any other ideas?

quicksketch’s picture

I came up with similar options @sun in the first iterations of #1833716: WYSIWYG: Introduce "Text editors" as part of filter format configuration (when we were manually injecting columns before the #type = table). I went with option #2 in the early versions of that patch.

I prefer the #2 approach for multiple reasons:
- Finding a header key is no longer dependent on a translated string (since it will now have a machine key).
- The header key having a consistent name means it's easy to find and other modules could easily change the translated string for a header column. i.e. $form['table']['#header']['title'] = t('Some other title');.
- The named approach makes it easy to handle situations where you either want your column to always be in a static position (say the second column) or when you want your column to always be after/before another column.

Even though array_slice() is probably the more efficient way to handle slotting in a column, with the second approach developers may be more comfortable with a significantly easier to understand typical foreach() loop.

$new_header = array();
foreach($element['#header'] as $key => $label) {
  $new_header[$key] = $label;
  if ($key === 'title') {
    $new_header['my_new_column'] = t('My new column');
  }
}
$element['#header'] = $new_header;
quicksketch’s picture

Here's alternative, more-efficient (but more confusing) code for splicing an item into the header. Assuming we went with option 2. @sun might be able to come up with something more readable or further efficient. Given the choice, I'd probably fallback to the "easy" approach using the foreach loop().

$position = array_search('name', array_keys($element['#header'])) + 1;
$start = array_splice($element['#header'], 0, $position);
$element['#header'] = array_merge($start, array('my_new_column' => t('My new column')), $element['#header']);
sun’s picture

Assigned: quicksketch » Unassigned
Category: feature » task
Status: Active » Needs review
StatusFileSize
new2.05 KB

Attached patch implements option #2.2.

quicksketch’s picture

+    // Sort columns by #header.
+    $element[$first]['#sorted'] = FALSE;
+    $weight = 0;
+    foreach ($element['#header'] as $key => $value) {
+      if (is_string($key) && isset($element[$first][$key])) {
+        $element[$first][$key]['#weight'] = $weight = $weight + 10;
+      }
+    }

This is an interesting approach that I wouldn't have thought of. My attempt would have been to use the keys from the $element['#header'] array to determine the order, rather than using element_children() to resort the columns. However the downside of that approach (but solved by the one in the patch) is that elements would have required a matching key in the header. This approach doesn't require a matching key, which should be useful in situations where the table doesn't have a header at all.

However if the developer were to make a mistake and skip a key, or a column was added without a header, the new column would end up in an unpredictable location, possibly throwing off the column matching.

Additionally, if the columns have weights already, the order could come out different than intended also.

Both of these seem like minor inconveniences, outweighed by the ability to easily inject a new column. In *every* place I've ever done a table like this, the column order is established by definition order (that is, they always have a weight of 0). Tables are always screwy if you don't have enough headers to match the columns and shouldn't be allowed anyway. Unless there are other objections, this looks good to go.

damien tournoud’s picture

I am for mandating matching keys, and mandating a #header structure, even if we can give the option not to display it (probably something along the lines of #title_display).

sun’s picture

Assigned: Unassigned » moshe weitzman

Wondering what @moshe thinks.

We might want & need to wrap the sorting logic into an !empty($element['#header']) to avoid PHP notices.

@quicksketch:
Regarding existing #weight properties on columns, yeah, those would be unconditionally overwritten, but in the end, that's the entire point of mapping the order from #header into table body row columns, right?

@Damien:
Making keys in #header required is probably a good idea, yeah. Otherwise, the alter-ability of each #type table would depend on whether a module author used an associative array in #header or not; i.e., inconsistency--.

Not sure though whether to require #header for all tables. It sounds "aggressive" to me, but yeah, we probably have to.

Also not sure how making the keys in #header required would change the proposed implementation? I played with a couple of alternate ways (array sorting functions, array_multisort(), etc), but all of them required much more expensive processing logic.

I don't think we really have to care for key/column mismatches. The resulting table output will be broken either way, so why baby-sit?

quicksketch’s picture

Regarding existing #weight properties on columns, yeah, those would be unconditionally overwritten, but in the end, that's the entire point of mapping the order from #header into table body row columns, right?

Ah, you're correct. I read this line incorrectly:

$element[$first][$key]['#weight'] = $weight = $weight + 10;

I read it as:

$element[$first][$key]['#weight'] = $weight = $element[$first][$key]['#weight'] + 10;

Which of course doesn't make any sense. I thought the existing #weight property was factoring in somehow, but it's not. Works for me.

One more thing to consider, what effect does this have on use of colspan? Prior to having the Operations column use a dropbutton, using a colspan of 3-4 was common on the operations column. However if you have 3 columns at the end, they obviously can't all have the same parent key in the $element['#table'] array, so the header column won't be able to group all of them together.

sun’s picture

Good question. The idea of colspans is generally incompatible with the proposed design thus far, since every key has to map to a column.

The typical and strict engineering answer to that would be: #header keys for colspans, sub-keys for columns.

'#header' => array(
  'dude-i-only-have-one:(' => array(
    'title' => ...
    'blah' => ...
  ),
)

Ugly. :) Although I almost guess that anything else would be horror in terms of declaration/API, as it's sorta guaranteed that it would involve custom + strangely named sub-properties.

'#header' => array(
  'title' => ...
  'blah' => array(
    'colspan' => array('view', 'edit', 'delete'),
  ),
)

Ugly. :)

Or even dynamic/conditional...

'#header' => array(
  'title' => ...
  'operations' => array(
    'view' => ...
    'edit' => ...
    'delete' => ...
  ),
)

...but that's invalid and obsolete already, since it is the prototype incarnation of "unpredictable" in a multidimensional modular system.

moshe weitzman’s picture

Patch so far looks good to me. I don't have an opinion yet on latest colspan discussion.

tstoeckler’s picture

I totally like the idea of mapping columns to header keys, because of its intuitiveness in declaring, i.e. I can do
$form['table'][5]['title'] = 'Foo'; and have have 'Foo' actually appear in the correct column. I don't understand, though, why we have given up on weights totally. We already need to support to specify header elements as arrays for anything non-trivial, so from the declaration perspective, I would find it weird for the following not to work:

$form['table']['#header'] = array(
  'operations' => array('data' => t('Operations'), 'weight' => 10),
  'title' => array('data' => t('Title'), 'weight' => 5),
);

Above the argument was made that you cannot possibly find default values for weights that are flexible enough for each possible use-case. But that is already the case with regular form elements, as well. It is the most common thing in the world if module B alters module A's form that it has to adjust the form elements' weights to make everything appear in the correct order. In core we can just go and change the original weight directly, but in contrib this is extremely common. And, especially think for things like 'operations' and tabledrag's 'weight' I do think we can find defaults that satisfy the 80% use-case. I definitely like setting a couple weights a lot more than array_splice()ing, even if it's just the header. And, again, I have never heard anyone argue for the same thing on the top-level $form level. Neither have I ever seen anyone array_splice()ing the whole $form. So I don't understand why here we are settling for something that seems sub-optimal from the start.

For supporting colspans I would suggest #weight as well. For example: (I know we don't do operations like this anymore, but bear with me)

$form['table']['#header']  = array(
  'title' => 'Title',
  'operations' => array('data' => 'Operations', 'colspan' => 2),
);
$form['table'][0]['title']['#markup'] = 'Foo';
$form['table'][0]['operations'] = array(
  'delete' => array(
    '#markup' => 'Delete',
    '#weight' => 10,
  ),
  'edit' => array(
    '#markup' => 'Edit',
    '#weight' => 5,
  ),
);

Of course, if you don't specify any weights, the declaration order would simply be taken over.

I think that would be the most intuitive way to declare this. I don't find specifying sub-keys in the header that great, but if we do want to do that, I think it should be explicitly declared instead of implied by additional array nesting. I.e. I would vote for @sun's second option (only that I would leave 'colspan' as is and name this bikeshed('sub-keys')).

quicksketch’s picture

@tstoeckler: You're second example uses an interesting but non-traditional way of declaring the operations column, because it defines multiple items inside what would normally be considered a single cell. If we adopted that structure officially, you wouldn't really need to specify colspan in the #header at all, because it would be apparent how big it needs to be based on the number of "subcells" that are specified in the matching key. Then we could keep our #header declaration simple to declare and just deduce that information from the matching key. However this would mean the logic for theme_table() would become even *more* messy, retroactively adjusting the header colspan based on the contents of cells.

tstoeckler’s picture

Well, I think if we do go with the concept of 'named columns' (which, to be clear, I totally think we should), I think the following would be extra weird:

$form['table']['#header']  = array(
  'title' => 'Title',
  'operations' => array('data' => 'Operations', 'colspan' => 2),
);
$form['table'][0]['title']['#markup'] = 'Foo';
$form['table'][0][] = array(
  '#markup' => 'Delete',
  '#weight' => 10,
);
$form['table'][0][] = array(
  '#markup' => 'Edit',
  '#weight' => 5,
);

I don't really see how we can reasonably place all elements in the same array level in the case of colspans.

Thinking about that, the example code in #12 might actually turn into a huge DX WTF on its own, because element_children($form['table'][0]) returns things which are maybe table cells, but not necessarily.

Hmm...

sun’s picture

Idea: #type 'colspan'

$form['table']['#header']  = array(
  'title' => 'Title',
  'operations' => 'Operations',
);

$form['table'][$id]['title']['#markup'] = 'Foo';

$form['table'][$id]['operations'] = array(
  '#type' => 'colspan', // Switch between 'dropbutton' and colspan'd rows easily!
);

$form['table'][$id]['operations']['edit'] = array(
  '#markup' => 'Edit',
);
$form['table'][$id]['operations']['delete'] = array(
  '#markup' => 'Delete',
);

The #type 'colspan' doesn't actually have any meaning for the element itself. It would be primarily used by drupal_pre_render_table() and the table #header sorting algorithm in order to determine which header columns need a colspan.

The #header definition would not actually contain a 'colspan' property, nor the amount of columns, or any column key names. Instead, the colspan is fully derived based on the table body row data declaration.

In the above, the 'Operations' header column automatically gets a 'colspan' => 2 assigned to it, since the 'operations' column is of #type 'colspan' and contains two child elements.

If you'd replace/_alter that #type from 'colspan' into 'dropbutton'/'operations', then the 'operations' column turns into a single and there's no more colspan involved.

This approach would make some sense to me. It has a logical/architectural though: It's actually the reverse of what the #header definition is doing for the sorting otherwise; i.e., the table body row columns impact the #header definition, but for the colspan facility only - aside from that, the #header definition's impact is negated: it has an impact on the table body row column order.

quicksketch’s picture

If you'd replace/_alter that #type from 'colspan' into 'dropbutton'/'operations', then the 'operations' column turns into a single and there's no more colspan involved.

Is this what we're doing already for operations (a #type = 'dropbutton')? If so, this solution looks ideal, that you could switch between using a dropbutton or multiple columns simply by changing a single word, and the structure of the form continues to stay the same between them. This approach has great advantages from an "alterability" standpoint. It's easy not only to add an additional "normal" column, but it's also easy to add into an operations column and have the header colspan automatically adjust.

sun’s picture

Assigned: moshe weitzman » Unassigned
Priority: Normal » Major

@Dries complained about temporary usage of array_slice() in #1855402: Add generic weighting (tabledrag) support for config entities (DraggableListController), so I'm bumping this to major.

I'm not 100% sure, but #15 looks like the best solution that's able to cope with colspans thus far. The general idea is that you always need data for a column.

  • If you do not have data, then you cannot remove a colspan.
  • If you have data for all columns, but you want a colspan, then you need to know how to merge the data.

#15 makes both ways explicit through the render array structure.

What #15 does not allow is to remove a colspan entirely. E.g.:

  1. You can have a colspan by setting the #type of the operations column to 'colspan'.
  2. You can have a dropbutton by setting the #type of the operations column to 'operations'.
  3. You can NOT have each operation in its own column (i.e., no colspan). At least not without changing the structure of all rows, and manually defining the new columns in #header.
quicksketch’s picture

You can NOT have each operation in its own column (i.e., no colspan). At least not without changing the structure of all rows, and manually defining the new columns in #header.

That sounds like a fine trade-off. And it's still possible, just difficult. Considering dropbuttons are already what we're using in most places and we already have this problem, this isn't introducing a new problem (just the same problem in a different format). I expect it to be uncommon enough it doesn't need any further consideration.

So, +1 #15. I'd love to have it.

We got away with array_splice() and manual changes in #1833716: WYSIWYG: Introduce "Text editors" as part of filter format configuration. So editor.module is another place in core that we can "fix" in this issue's patch.

joelpittet’s picture

jibran’s picture

#5: drupal8.table-column-sort.5.patch queued for re-testing.

andypost’s picture

In the above, the 'Operations' header column automatically gets a 'colspan' => 2 assigned to it, since the 'operations' column is of #type 'colspan' and contains two child elements.

Look really bad that #header depends on data columns.

I'd prefer more strict as #7 suggests! the only questionable part - should we drop data cells that does not have keys in #header?

benjifisher’s picture

#5: drupal8.table-column-sort.5.patch queued for re-testing.

thedavidmeister’s picture

Looks like the patch no longer applies:

error: core/modules/filter/filter.admin.inc: No such file or directory

thedavidmeister’s picture

Status: Needs review » Needs work
wim leers’s picture

I was looking through the remaining @todos for editor.module and leveraging the changes of this issue to simplify some code is one of them.

Can this still happen in D8? Or is it D9 material?

andypost’s picture

This issue is about approach and DX but there's no conclusion, suppose we should fix this in D8

andypost’s picture

Issue summary: View changes

Updated issue summary.

tim.plunkett’s picture

Priority: Major » Normal
Issue summary: View changes

We worked around this so far, except editor.module.

sun’s picture

andypost’s picture

Issue tags: +Needs reroll
rosk0’s picture

Status: Needs work » Needs review
Issue tags: -Needs reroll

Reroll.

Changes in filter.admin.inc was overridden by #1987124: Convert filter_admin_format_page() and filter_admin_overview() to a Controller in 93ea9a472b988ac8d1aa56f947bc916c24f8ea73, so doesn't included.

akalata’s picture

Status: Needs review » Needs work

#30 didn't have a patch

Version: 8.0.x-dev » 8.1.x-dev

Drupal 8.0.6 was released on April 6 and is the final bugfix release for the Drupal 8.0.x series. Drupal 8.0.x will not receive any further development aside from security fixes. Drupal 8.1.0-rc1 is now available and sites should prepare to update to 8.1.0.

Bug reports should be targeted against the 8.1.x-dev branch from now on, and new development or disruptive changes should be targeted against the 8.2.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

Version: 8.1.x-dev » 8.2.x-dev

Drupal 8.1.9 was released on September 7 and is the final bugfix release for the Drupal 8.1.x series. Drupal 8.1.x will not receive any further development aside from security fixes. Drupal 8.2.0-rc1 is now available and sites should prepare to upgrade to 8.2.0.

Bug reports should be targeted against the 8.2.x-dev branch from now on, and new development or disruptive changes should be targeted against the 8.3.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

Version: 8.2.x-dev » 8.3.x-dev

Drupal 8.2.6 was released on February 1, 2017 and is the final full bugfix release for the Drupal 8.2.x series. Drupal 8.2.x will not receive any further development aside from critical and security fixes. Sites should prepare to update to 8.3.0 on April 5, 2017. (Drupal 8.3.0-alpha1 is available for testing.)

Bug reports should be targeted against the 8.3.x-dev branch from now on, and new development or disruptive changes should be targeted against the 8.4.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

Version: 8.3.x-dev » 8.4.x-dev

Drupal 8.3.6 was released on August 2, 2017 and is the final full bugfix release for the Drupal 8.3.x series. Drupal 8.3.x will not receive any further development aside from critical and security fixes. Sites should prepare to update to 8.4.0 on October 4, 2017. (Drupal 8.4.0-alpha1 is available for testing.)

Bug reports should be targeted against the 8.4.x-dev branch from now on, and new development or disruptive changes should be targeted against the 8.5.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

Version: 8.4.x-dev » 8.5.x-dev

Drupal 8.4.4 was released on January 3, 2018 and is the final full bugfix release for the Drupal 8.4.x series. Drupal 8.4.x will not receive any further development aside from critical and security fixes. Sites should prepare to update to 8.5.0 on March 7, 2018. (Drupal 8.5.0-alpha1 is available for testing.)

Bug reports should be targeted against the 8.5.x-dev branch from now on, and new development or disruptive changes should be targeted against the 8.6.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

Version: 8.5.x-dev » 8.6.x-dev

Drupal 8.5.6 was released on August 1, 2018 and is the final bugfix release for the Drupal 8.5.x series. Drupal 8.5.x will not receive any further development aside from security fixes. Sites should prepare to update to 8.6.0 on September 5, 2018. (Drupal 8.6.0-rc1 is available for testing.)

Bug reports should be targeted against the 8.6.x-dev branch from now on, and new development or disruptive changes should be targeted against the 8.7.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

Version: 8.6.x-dev » 8.8.x-dev

Drupal 8.6.x will not receive any further development aside from security fixes. Bug reports should be targeted against the 8.8.x-dev branch from now on, and new development or disruptive changes should be targeted against the 8.9.x-dev branch. For more information see the Drupal 8 and 9 minor version schedule and the Allowed changes during the Drupal 8 and 9 release cycles.

Version: 8.8.x-dev » 8.9.x-dev

Drupal 8.8.7 was released on June 3, 2020 and is the final full bugfix release for the Drupal 8.8.x series. Drupal 8.8.x will not receive any further development aside from security fixes. Sites should prepare to update to Drupal 8.9.0 or Drupal 9.0.0 for ongoing support.

Bug reports should be targeted against the 8.9.x-dev branch from now on, and new development or disruptive changes should be targeted against the 9.1.x-dev branch. For more information see the Drupal 8 and 9 minor version schedule and the Allowed changes during the Drupal 8 and 9 release cycles.

Version: 8.9.x-dev » 9.2.x-dev

Drupal 8 is end-of-life as of November 17, 2021. There will not be further changes made to Drupal 8. Bugfixes are now made to the 9.3.x and higher branches only. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 9.2.x-dev » 9.3.x-dev

Version: 9.3.x-dev » 9.4.x-dev

Drupal 9.3.15 was released on June 1st, 2022 and is the final full bugfix release for the Drupal 9.3.x series. Drupal 9.3.x will not receive any further development aside from security fixes. Drupal 9 bug reports should be targeted for the 9.4.x-dev branch from now on, and new development or disruptive changes should be targeted for the 9.5.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 9.4.x-dev » 9.5.x-dev

Drupal 9.4.9 was released on December 7, 2022 and is the final full bugfix release for the Drupal 9.4.x series. Drupal 9.4.x will not receive any further development aside from security fixes. Drupal 9 bug reports should be targeted for the 9.5.x-dev branch from now on, and new development or disruptive changes should be targeted for the 10.1.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 9.5.x-dev » 11.x-dev

Drupal core is moving towards using a “main” branch. As an interim step, a new 11.x branch has been opened, as Drupal.org infrastructure cannot currently fully support a branch named main. New developments and disruptive changes should now be targeted for the 11.x branch. For more information, see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

smustgrave’s picture

Status: Needs work » Postponed (maintainer needs more info)
Issue tags: +stale-issue-cleanup

Thank you for creating this issue to improve Drupal.

We are working to decide if this task is still relevant to a currently supported version of Drupal. There hasn't been any discussion here for over 8 years which suggests that this has either been implemented or is no longer relevant. Your thoughts on this will allow a decision to be made.

Since we need more information to move forward with this issue, the status is now Postponed (maintainer needs more info). If we don't receive additional information to help with the issue, it may be closed after three months.

Thanks!

amateescu’s picture

Category: Task » Feature request
Status: Postponed (maintainer needs more info) » Needs work

This looks like a valid feature request still.

Version: 11.x-dev » main

Drupal core is now using the main branch as the primary development branch. New developments and disruptive changes should now be targeted to the main branch.

Read more in the announcement.