1<?php 2 3namespace Drupal\Core\Render\Element; 4 5use Drupal\Core\Form\FormStateInterface; 6use Drupal\Core\Render\Element; 7use Drupal\Component\Utility\Html as HtmlUtility; 8 9/** 10 * Provides a render element for a table. 11 * 12 * Note: Although this extends FormElement, it can be used outside the 13 * context of a form. 14 * 15 * Properties: 16 * - #header: An array of table header labels. 17 * - #rows: An array of the rows to be displayed. Each row is either an array 18 * of cell contents or an array of properties as described in table.html.twig 19 * Alternatively specify the data for the table as child elements of the table 20 * element. Table elements would contain rows elements that would in turn 21 * contain column elements. 22 * - #empty: Text to display when no rows are present. 23 * - #responsive: Indicates whether to add the drupal.responsive_table library 24 * providing responsive tables. Defaults to TRUE. 25 * - #sticky: Indicates whether to add the drupal.tableheader library that makes 26 * table headers always visible at the top of the page. Defaults to FALSE. 27 * 28 * Usage example: 29 * @code 30 * $form['contacts'] = array( 31 * '#type' => 'table', 32 * '#caption' => $this->t('Sample Table'), 33 * '#header' => array($this->t('Name'), $this->t('Phone')), 34 * ); 35 * 36 * for ($i = 1; $i <= 4; $i++) { 37 * $form['contacts'][$i]['#attributes'] = array('class' => array('foo', 'baz')); 38 * $form['contacts'][$i]['name'] = array( 39 * '#type' => 'textfield', 40 * '#title' => $this->t('Name'), 41 * '#title_display' => 'invisible', 42 * ); 43 * 44 * $form['contacts'][$i]['phone'] = array( 45 * '#type' => 'tel', 46 * '#title' => $this->t('Phone'), 47 * '#title_display' => 'invisible', 48 * ); 49 * } 50 * 51 * $form['contacts'][]['colspan_example'] = array( 52 * '#plain_text' => 'Colspan Example', 53 * '#wrapper_attributes' => array('colspan' => 2, 'class' => array('foo', 'bar')), 54 * ); 55 * @endcode 56 * @see \Drupal\Core\Render\Element\Tableselect 57 * 58 * @FormElement("table") 59 */ 60class Table extends FormElement { 61 62 /** 63 * {@inheritdoc} 64 */ 65 public function getInfo() { 66 $class = get_class($this); 67 return [ 68 '#header' => [], 69 '#rows' => [], 70 '#empty' => '', 71 // Properties for tableselect support. 72 '#input' => TRUE, 73 '#tree' => TRUE, 74 '#tableselect' => FALSE, 75 '#sticky' => FALSE, 76 '#responsive' => TRUE, 77 '#multiple' => TRUE, 78 '#js_select' => TRUE, 79 '#process' => [ 80 [$class, 'processTable'], 81 ], 82 '#element_validate' => [ 83 [$class, 'validateTable'], 84 ], 85 // Properties for tabledrag support. 86 // The value is a list of arrays that are passed to 87 // drupal_attach_tabledrag(). Table::preRenderTable() prepends the HTML ID 88 // of the table to each set of options. 89 // @see drupal_attach_tabledrag() 90 '#tabledrag' => [], 91 // Render properties. 92 '#pre_render' => [ 93 [$class, 'preRenderTable'], 94 ], 95 '#theme' => 'table', 96 ]; 97 } 98 99 /** 100 * {@inheritdoc} 101 */ 102 public static function valueCallback(&$element, $input, FormStateInterface $form_state) { 103 // If #multiple is FALSE, the regular default value of radio buttons is used. 104 if (!empty($element['#tableselect']) && !empty($element['#multiple'])) { 105 // Contrary to #type 'checkboxes', the default value of checkboxes in a 106 // table is built from the array keys (instead of array values) of the 107 // #default_value property. 108 // @todo D8: Remove this inconsistency. 109 if ($input === FALSE) { 110 $element += ['#default_value' => []]; 111 $value = array_keys(array_filter($element['#default_value'])); 112 return array_combine($value, $value); 113 } 114 else { 115 return is_array($input) ? array_combine($input, $input) : []; 116 } 117 } 118 } 119 120 /** 121 * #process callback for #type 'table' to add tableselect support. 122 * 123 * @param array $element 124 * An associative array containing the properties and children of the 125 * table element. 126 * @param \Drupal\Core\Form\FormStateInterface $form_state 127 * The current state of the form. 128 * @param array $complete_form 129 * The complete form structure. 130 * 131 * @return array 132 * The processed element. 133 */ 134 public static function processTable(&$element, FormStateInterface $form_state, &$complete_form) { 135 if ($element['#tableselect']) { 136 if ($element['#multiple']) { 137 $value = is_array($element['#value']) ? $element['#value'] : []; 138 } 139 // Advanced selection behavior makes no sense for radios. 140 else { 141 $element['#js_select'] = FALSE; 142 } 143 // Add a "Select all" checkbox column to the header. 144 // @todo D8: Rename into #select_all? 145 if ($element['#js_select']) { 146 $element['#attached']['library'][] = 'core/drupal.tableselect'; 147 array_unshift($element['#header'], ['class' => ['select-all']]); 148 } 149 // Add an empty header column for radio buttons or when a "Select all" 150 // checkbox is not desired. 151 else { 152 array_unshift($element['#header'], ''); 153 } 154 155 if (!isset($element['#default_value']) || $element['#default_value'] === 0) { 156 $element['#default_value'] = []; 157 } 158 // Create a checkbox or radio for each row in a way that the value of the 159 // tableselect element behaves as if it had been of #type checkboxes or 160 // radios. 161 foreach (Element::children($element) as $key) { 162 $row = &$element[$key]; 163 // Prepare the element #parents for the tableselect form element. 164 // Their values have to be located in child keys (#tree is ignored), 165 // since Table::validateTable() has to be able to validate whether input 166 // (for the parent #type 'table' element) has been submitted. 167 $element_parents = array_merge($element['#parents'], [$key]); 168 169 // Since the #parents of the tableselect form element will equal the 170 // #parents of the row element, prevent FormBuilder from auto-generating 171 // an #id for the row element, since 172 // \Drupal\Component\Utility\Html::getUniqueId() would automatically 173 // append a suffix to the tableselect form element's #id otherwise. 174 $row['#id'] = HtmlUtility::getUniqueId('edit-' . implode('-', $element_parents) . '-row'); 175 176 // Do not overwrite manually created children. 177 if (!isset($row['select'])) { 178 // Determine option label; either an assumed 'title' column, or the 179 // first available column containing a #title or #markup. 180 // @todo Consider to add an optional $element[$key]['#title_key'] 181 // defaulting to 'title'? 182 unset($label_element); 183 $title = NULL; 184 if (isset($row['title']['#type']) && $row['title']['#type'] == 'label') { 185 $label_element = &$row['title']; 186 } 187 else { 188 if (!empty($row['title']['#title'])) { 189 $title = $row['title']['#title']; 190 } 191 else { 192 foreach (Element::children($row) as $column) { 193 if (isset($row[$column]['#title'])) { 194 $title = $row[$column]['#title']; 195 break; 196 } 197 if (isset($row[$column]['#markup'])) { 198 $title = $row[$column]['#markup']; 199 break; 200 } 201 } 202 } 203 if (isset($title) && $title !== '') { 204 $title = t('Update @title', ['@title' => $title]); 205 } 206 } 207 208 // Prepend the select column to existing columns. 209 $row = ['select' => []] + $row; 210 $row['select'] += [ 211 '#type' => $element['#multiple'] ? 'checkbox' : 'radio', 212 '#id' => HtmlUtility::getUniqueId('edit-' . implode('-', $element_parents)), 213 // @todo If rows happen to use numeric indexes instead of string keys, 214 // this results in a first row with $key === 0, which is always FALSE. 215 '#return_value' => $key, 216 '#attributes' => $element['#attributes'], 217 '#wrapper_attributes' => [ 218 'class' => ['table-select'], 219 ], 220 ]; 221 if ($element['#multiple']) { 222 $row['select']['#default_value'] = isset($value[$key]) ? $key : NULL; 223 $row['select']['#parents'] = $element_parents; 224 } 225 else { 226 $row['select']['#default_value'] = ($element['#default_value'] == $key ? $key : NULL); 227 $row['select']['#parents'] = $element['#parents']; 228 } 229 if (isset($label_element)) { 230 $label_element['#id'] = $row['select']['#id'] . '--label'; 231 $label_element['#for'] = $row['select']['#id']; 232 $row['select']['#attributes']['aria-labelledby'] = $label_element['#id']; 233 $row['select']['#title_display'] = 'none'; 234 } 235 else { 236 $row['select']['#title'] = $title; 237 $row['select']['#title_display'] = 'invisible'; 238 } 239 } 240 } 241 } 242 243 return $element; 244 } 245 246 /** 247 * #element_validate callback for #type 'table'. 248 * 249 * @param array $element 250 * An associative array containing the properties and children of the 251 * table element. 252 * @param \Drupal\Core\Form\FormStateInterface $form_state 253 * The current state of the form. 254 * @param array $complete_form 255 * The complete form structure. 256 */ 257 public static function validateTable(&$element, FormStateInterface $form_state, &$complete_form) { 258 // Skip this validation if the button to submit the form does not require 259 // selected table row data. 260 $triggering_element = $form_state->getTriggeringElement(); 261 if (empty($triggering_element['#tableselect'])) { 262 return; 263 } 264 if ($element['#multiple']) { 265 if (!is_array($element['#value']) || !count(array_filter($element['#value']))) { 266 $form_state->setError($element, t('No items selected.')); 267 } 268 } 269 elseif (!isset($element['#value']) || $element['#value'] === '') { 270 $form_state->setError($element, t('No item selected.')); 271 } 272 } 273 274 /** 275 * #pre_render callback to transform children of an element of #type 'table'. 276 * 277 * This function converts sub-elements of an element of #type 'table' to be 278 * suitable for table.html.twig: 279 * - The first level of sub-elements are table rows. Only the #attributes 280 * property is taken into account. 281 * - The second level of sub-elements is converted into columns for the 282 * corresponding first-level table row. 283 * 284 * Simple example usage: 285 * @code 286 * $form['table'] = array( 287 * '#type' => 'table', 288 * '#header' => array($this->t('Title'), array('data' => $this->t('Operations'), 'colspan' => '1')), 289 * // Optionally, to add tableDrag support: 290 * '#tabledrag' => array( 291 * array( 292 * 'action' => 'order', 293 * 'relationship' => 'sibling', 294 * 'group' => 'thing-weight', 295 * ), 296 * ), 297 * ); 298 * foreach ($things as $row => $thing) { 299 * $form['table'][$row]['#weight'] = $thing['weight']; 300 * 301 * $form['table'][$row]['title'] = array( 302 * '#type' => 'textfield', 303 * '#default_value' => $thing['title'], 304 * ); 305 * 306 * // Optionally, to add tableDrag support: 307 * $form['table'][$row]['#attributes']['class'][] = 'draggable'; 308 * $form['table'][$row]['weight'] = array( 309 * '#type' => 'textfield', 310 * '#title' => $this->t('Weight for @title', array('@title' => $thing['title'])), 311 * '#title_display' => 'invisible', 312 * '#size' => 4, 313 * '#default_value' => $thing['weight'], 314 * '#attributes' => array('class' => array('thing-weight')), 315 * ); 316 * 317 * // The amount of link columns should be identical to the 'colspan' 318 * // attribute in #header above. 319 * $form['table'][$row]['edit'] = array( 320 * '#type' => 'link', 321 * '#title' => $this->t('Edit'), 322 * '#url' => Url::fromRoute('entity.test_entity.edit_form', ['test_entity' => $row]), 323 * ); 324 * } 325 * @endcode 326 * 327 * @param array $element 328 * A structured array containing two sub-levels of elements. Properties used: 329 * - #tabledrag: The value is a list of $options arrays that are passed to 330 * drupal_attach_tabledrag(). The HTML ID of the table is added to each 331 * $options array. 332 * 333 * @return array 334 * 335 * @see template_preprocess_table() 336 * @see \Drupal\Core\Render\AttachmentsResponseProcessorInterface::processAttachments() 337 * @see drupal_attach_tabledrag() 338 */ 339 public static function preRenderTable($element) { 340 foreach (Element::children($element) as $first) { 341 $row = ['data' => []]; 342 // Apply attributes of first-level elements as table row attributes. 343 if (isset($element[$first]['#attributes'])) { 344 $row += $element[$first]['#attributes']; 345 } 346 // Turn second-level elements into table row columns. 347 // @todo Do not render a cell for children of #type 'value'. 348 // @see https://www.drupal.org/node/1248940 349 foreach (Element::children($element[$first]) as $second) { 350 // Assign the element by reference, so any potential changes to the 351 // original element are taken over. 352 $column = ['data' => &$element[$first][$second]]; 353 354 // Apply wrapper attributes of second-level elements as table cell 355 // attributes. 356 if (isset($element[$first][$second]['#wrapper_attributes'])) { 357 $column += $element[$first][$second]['#wrapper_attributes']; 358 } 359 360 $row['data'][] = $column; 361 } 362 $element['#rows'][] = $row; 363 } 364 365 // Take over $element['#id'] as HTML ID attribute, if not already set. 366 Element::setAttributes($element, ['id']); 367 368 // Add sticky headers, if applicable. 369 if (count($element['#header']) && $element['#sticky']) { 370 $element['#attached']['library'][] = 'core/drupal.tableheader'; 371 // Add 'sticky-enabled' class to the table to identify it for JS. 372 // This is needed to target tables constructed by this function. 373 $element['#attributes']['class'][] = 'sticky-enabled'; 374 } 375 // If the table has headers and it should react responsively to columns hidden 376 // with the classes represented by the constants RESPONSIVE_PRIORITY_MEDIUM 377 // and RESPONSIVE_PRIORITY_LOW, add the tableresponsive behaviors. 378 if (count($element['#header']) && $element['#responsive']) { 379 $element['#attached']['library'][] = 'core/drupal.tableresponsive'; 380 // Add 'responsive-enabled' class to the table to identify it for JS. 381 // This is needed to target tables constructed by this function. 382 $element['#attributes']['class'][] = 'responsive-enabled'; 383 } 384 385 // If the custom #tabledrag is set and there is a HTML ID, add the table's 386 // HTML ID to the options and attach the behavior. 387 if (!empty($element['#tabledrag']) && isset($element['#attributes']['id'])) { 388 foreach ($element['#tabledrag'] as $options) { 389 $options['table_id'] = $element['#attributes']['id']; 390 drupal_attach_tabledrag($element, $options); 391 } 392 } 393 394 return $element; 395 } 396 397} 398