Table.php 15 KB

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