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