diff --git a/core/core.services.yml b/core/core.services.yml index 200972e73b3..2f04124df99 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -749,6 +749,9 @@ services: plugin.manager.condition: class: Drupal\Core\Condition\ConditionManager parent: default_plugin_manager + plugin.manager.element_info: + class: Drupal\Core\Render\ElementInfoManager + parent: default_plugin_manager kernel_destruct_subscriber: class: Drupal\Core\EventSubscriber\KernelDestructionSubscriber tags: @@ -907,8 +910,7 @@ services: info_parser: class: Drupal\Core\Extension\InfoParser element_info: - class: Drupal\Core\Render\ElementInfo - arguments: ['@module_handler'] + alias: plugin.manager.element_info file.mime_type.guesser: class: Drupal\Core\File\MimeType\MimeTypeGuesser tags: diff --git a/core/includes/ajax.inc b/core/includes/ajax.inc index 4a03c6869b6..3b5c2779b13 100644 --- a/core/includes/ajax.inc +++ b/core/includes/ajax.inc @@ -6,6 +6,7 @@ */ use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Render\Element; /** * @defgroup ajax Ajax API @@ -162,20 +163,10 @@ use Drupal\Core\Form\FormStateInterface; /** * Form element processing handler for the #ajax form property. * - * @param $element - * An associative array containing the properties of the element. - * - * @return - * The processed element. - * - * @see ajax_pre_render_element() + * @deprecated Use \Drupal\Core\Render\Element\FormElement::processAjaxForm(). */ -function ajax_process_form($element, FormStateInterface $form_state) { - $element = ajax_pre_render_element($element); - if (!empty($element['#ajax_processed'])) { - $form_state['cache'] = TRUE; - } - return $element; +function ajax_process_form($element, FormStateInterface $form_state, &$complete_form) { + return Element\FormElement::processAjaxForm($element, $form_state, $complete_form); } /** diff --git a/core/includes/common.inc b/core/includes/common.inc index 6c6c4a3a88a..18ed1d51046 100644 --- a/core/includes/common.inc +++ b/core/includes/common.inc @@ -2670,65 +2670,10 @@ function drupal_pre_render_html_tag($element) { /** * Pre-render callback: Renders a link into #markup. * - * Doing so during pre_render gives modules a chance to alter the link parts. - * - * @param $elements - * A structured array whose keys form the arguments to l(): - * - #title: The link text to pass as argument to l(). - * - One of the following - * - #route_name and (optionally) and a #route_parameters array; The route - * name and route parameters which will be passed into the link generator. - * - #href: The system path or URL to pass as argument to l(). - * - #options: (optional) An array of options to pass to l() or the link - * generator. - * - * @return - * The passed-in elements containing a rendered link in '#markup'. + * @deprecated Use \Drupal\Core\Render\Element\Link::preRenderLink(). */ function drupal_pre_render_link($element) { - // By default, link options to pass to l() are normally set in #options. - $element += array('#options' => array()); - // However, within the scope of renderable elements, #attributes is a valid - // way to specify attributes, too. Take them into account, but do not override - // attributes from #options. - if (isset($element['#attributes'])) { - $element['#options'] += array('attributes' => array()); - $element['#options']['attributes'] += $element['#attributes']; - } - - // This #pre_render callback can be invoked from inside or outside of a Form - // API context, and depending on that, a HTML ID may be already set in - // different locations. #options should have precedence over Form API's #id. - // #attributes have been taken over into #options above already. - if (isset($element['#options']['attributes']['id'])) { - $element['#id'] = $element['#options']['attributes']['id']; - } - elseif (isset($element['#id'])) { - $element['#options']['attributes']['id'] = $element['#id']; - } - - // Conditionally invoke ajax_pre_render_element(), if #ajax is set. - if (isset($element['#ajax']) && !isset($element['#ajax_processed'])) { - // If no HTML ID was found above, automatically create one. - if (!isset($element['#id'])) { - $element['#id'] = $element['#options']['attributes']['id'] = drupal_html_id('ajax-link'); - } - // If #ajax['path] was not specified, use the href as Ajax request URL. - if (!isset($element['#ajax']['path'])) { - $element['#ajax']['path'] = $element['#href']; - $element['#ajax']['options'] = $element['#options']; - } - $element = ajax_pre_render_element($element); - } - - if (isset($element['#route_name'])) { - $element['#route_parameters'] = empty($element['#route_parameters']) ? array() : $element['#route_parameters']; - $element['#markup'] = \Drupal::linkGenerator()->generate($element['#title'], $element['#route_name'], $element['#route_parameters'], $element['#options']); - } - else { - $element['#markup'] = l($element['#title'], $element['#href'], $element['#options']); - } - return $element; + return Element\Link::preRenderLink($element); } /** diff --git a/core/includes/form.inc b/core/includes/form.inc index f49dbd26fd1..fb9add490ad 100644 --- a/core/includes/form.inc +++ b/core/includes/form.inc @@ -566,22 +566,10 @@ function form_type_select_value($element, $input = FALSE) { /** * Determines the value for a textfield form element. * - * @param $element - * The form element whose value is being populated. - * @param $input - * The incoming input to populate the form element. If this is FALSE, - * the element's default value should be returned. - * - * @return - * The data that will appear in the $element_state['values'] collection - * for this element. Return nothing to use the default. + * @deprecated Use \Drupal\Core\Render\Element\Textfield::valueCallback(). */ -function form_type_textfield_value($element, $input = FALSE) { - if ($input !== FALSE && $input !== NULL) { - // Equate $input to the form value to ensure it's marked for - // validation. - return str_replace(array("\r", "\n"), '', $input); - } +function form_type_textfield_value(&$element, $input, &$form_state) { + return Element\Textfield::valueCallback($element, $input, $form_state); } /** @@ -1306,24 +1294,10 @@ function form_pre_render_actions_dropbutton(array $element) { /** * #process callback for #pattern form element property. * - * @param $element - * An associative array containing the properties and children of the - * generic input element. - * @param $form_state - * The current state of the form for the form this element belongs to. - * - * @return - * The processed element. - * - * @see form_validate_pattern() + * @deprecated Use \Drupal\Core\Render\Element\FormElement::processPattern(). */ -function form_process_pattern($element, FormStateInterface $form_state) { - if (isset($element['#pattern']) && !isset($element['#attributes']['pattern'])) { - $element['#attributes']['pattern'] = $element['#pattern']; - $element['#element_validate'][] = 'form_validate_pattern'; - } - - return $element; +function form_process_pattern($element, FormStateInterface $form_state, &$complete_form) { + return Element\FormElement::processPattern($element, $form_state, $complete_form); } /** @@ -1922,61 +1896,10 @@ function form_pre_render_details($element) { /** * Adds members of this group as actual elements for rendering. * - * @param $element - * An associative array containing the properties and children of the - * element. - * - * @return - * The modified element with all group members. + * @deprecated Use \Drupal\Core\Render\ElementElementBase::preRenderGroup(). */ function form_pre_render_group($element) { - // The element may be rendered outside of a Form API context. - if (!isset($element['#parents']) || !isset($element['#groups'])) { - return $element; - } - - // Inject group member elements belonging to this group. - $parents = implode('][', $element['#parents']); - $children = Element::children($element['#groups'][$parents]); - if (!empty($children)) { - foreach ($children as $key) { - // Break references and indicate that the element should be rendered as - // group member. - $child = (array) $element['#groups'][$parents][$key]; - $child['#group_details'] = TRUE; - // Inject the element as new child element. - $element[] = $child; - - $sort = TRUE; - } - // Re-sort the element's children if we injected group member elements. - if (isset($sort)) { - $element['#sorted'] = FALSE; - } - } - - if (isset($element['#group'])) { - // Contains form element summary functionalities. - $element['#attached']['library'][] = 'core/drupal.form'; - - $group = $element['#group']; - // If this element belongs to a group, but the group-holding element does - // not exist, we need to render it (at its original location). - if (!isset($element['#groups'][$group]['#group_exists'])) { - // Intentionally empty to clarify the flow; we simply return $element. - } - // If we injected this element into the group, then we want to render it. - elseif (!empty($element['#group_details'])) { - // Intentionally empty to clarify the flow; we simply return $element. - } - // Otherwise, this element belongs to a group and the group exists, so we do - // not render it. - elseif (Element::children($element['#groups'][$group])) { - $element['#printed'] = TRUE; - } - } - - return $element; + return Element\RenderElement::preRenderGroup($element); } /** @@ -2064,43 +1987,10 @@ function template_preprocess_vertical_tabs(&$variables) { * Adds autocomplete functionality to elements with a valid * #autocomplete_route_name. * - * Suppose your autocomplete route name is 'mymodule.autocomplete' and its path - * is: '/mymodule/autocomplete/{a}/{b}' - * In your form you have: - * @code - * '#autocomplete_route_name' => 'mymodule.autocomplete', - * '#autocomplete_route_parameters' => array('a' => $some_key, 'b' => $some_id), - * @endcode - * The user types in "keywords" so the full path called is: - * 'mymodule_autocomplete/$some_key/$some_id?q=keywords' - * - * @param array $element - * The form element to process. Properties used: - * - #autocomplete_route_name: A route to be used as callback URL by the - * autocomplete JavaScript library. - * - #autocomplete_route_parameters: The parameters to be used in conjunction - * with the route name. - * @param \Drupal\Core\Form\FormStateInterface $form_state - * The current state of the form. - * - * @return array - * The form element. + * @deprecated Use \Drupal\Core\Render\Element\FormElement::processAutocomplete(). */ -function form_process_autocomplete($element, FormStateInterface $form_state) { - $access = FALSE; - if (!empty($element['#autocomplete_route_name'])) { - $parameters = isset($element['#autocomplete_route_parameters']) ? $element['#autocomplete_route_parameters'] : array(); - - $path = \Drupal::urlGenerator()->generate($element['#autocomplete_route_name'], $parameters); - $access = \Drupal::service('access_manager')->checkNamedRoute($element['#autocomplete_route_name'], $parameters, \Drupal::currentUser()); - } - if ($access) { - $element['#attributes']['class'][] = 'form-autocomplete'; - $element['#attached']['library'][] = 'core/drupal.autocomplete'; - // Provide a data attribute for the JavaScript behavior to bind to. - $element['#attributes']['data-autocomplete-path'] = $path; - } - return $element; +function form_process_autocomplete($element, FormStateInterface $form_state, &$complete_form) { + return Element\FormElement::processAutocomplete($element, $form_state, $complete_form); } /** @@ -2206,20 +2096,10 @@ function form_pre_render_hidden($element) { /** * Prepares a #type 'textfield' render element for theme_input(). * - * @param array $element - * An associative array containing the properties of the element. - * Properties used: #title, #value, #description, #size, #maxlength, - * #placeholder, #required, #attributes. - * - * @return array - * The $element with prepared variables ready for theme_input(). + * @deprecated Use \Drupal\Core\Render\Element\Textfield::preRenderTextfield(). */ function form_pre_render_textfield($element) { - $element['#attributes']['type'] = 'text'; - Element::setAttributes($element, array('id', 'name', 'value', 'size', 'maxlength', 'placeholder')); - _form_set_attributes($element, array('form-text')); - - return $element; + return Element\Textfield::preRenderTextfield($element); } /** diff --git a/core/lib/Drupal/Core/Form/FormBuilder.php b/core/lib/Drupal/Core/Form/FormBuilder.php index adbb8f33568..d4170a41281 100644 --- a/core/lib/Drupal/Core/Form/FormBuilder.php +++ b/core/lib/Drupal/Core/Form/FormBuilder.php @@ -938,7 +938,14 @@ class FormBuilder implements FormBuilderInterface, FormValidatorInterface, FormS // Set the element's #value property. if (!isset($element['#value']) && !array_key_exists('#value', $element)) { + // @todo Once all elements are converted to plugins in + // https://www.drupal.org/node/2311393, rely on + // $element['#value_callback'] directly. $value_callable = !empty($element['#value_callback']) ? $element['#value_callback'] : 'form_type_' . $element['#type'] . '_value'; + if (!is_callable($value_callable)) { + $value_callable = '\Drupal\Core\Render\Element\FormElement::valueCallback'; + } + if ($process_input) { // Get the input for the current element. NULL values in the input need // to be explicitly distinguished from missing input. (see below) @@ -962,9 +969,8 @@ class FormBuilder implements FormBuilderInterface, FormValidatorInterface, FormS // If we have input for the current element, assign it to the #value // property, optionally filtered through $value_callback. if ($input_exists) { - if (is_callable($value_callable)) { - $element['#value'] = call_user_func_array($value_callable, array(&$element, $input, &$form_state)); - } + $element['#value'] = call_user_func_array($value_callable, array(&$element, $input, &$form_state)); + if (!isset($element['#value']) && isset($input)) { $element['#value'] = $input; } @@ -978,9 +984,8 @@ class FormBuilder implements FormBuilderInterface, FormValidatorInterface, FormS if (!isset($element['#value'])) { // Call #type_value without a second argument to request default_value // handling. - if (is_callable($value_callable)) { - $element['#value'] = call_user_func_array($value_callable, array(&$element, FALSE, &$form_state)); - } + $element['#value'] = call_user_func_array($value_callable, array(&$element, FALSE, &$form_state)); + // Final catch. If we haven't set a value yet, use the explicit default // value. Avoid image buttons (which come with garbage value), so we // only get value for the button actually clicked. diff --git a/core/lib/Drupal/Core/Render/Annotation/FormElement.php b/core/lib/Drupal/Core/Render/Annotation/FormElement.php new file mode 100644 index 00000000000..4d4b59339c7 --- /dev/null +++ b/core/lib/Drupal/Core/Render/Annotation/FormElement.php @@ -0,0 +1,32 @@ + 'attributename'). If both names are identical - * except for the leading '#', then an attribute name value is sufficient and - * no property name needs to be specified. + * An associative array whose keys are element property names and whose + * values are the HTML attribute names to set on the corresponding + * property; e.g., array('#propertyname' => 'attributename'). If both names + * are identical except for the leading '#', then an attribute name value is + * sufficient and no property name needs to be specified. */ public static function setAttributes(array &$element, array $map) { foreach ($map as $property => $attribute) { diff --git a/core/lib/Drupal/Core/Render/Element/ElementInterface.php b/core/lib/Drupal/Core/Render/Element/ElementInterface.php new file mode 100644 index 00000000000..a1035f7b2b7 --- /dev/null +++ b/core/lib/Drupal/Core/Render/Element/ElementInterface.php @@ -0,0 +1,45 @@ + 'mymodule.autocomplete', + * '#autocomplete_route_parameters' => array('a' => $some_key, 'b' => $some_id), + * @endcode + * If the user types "keywords" in that field, the full path called would be: + * 'mymodule_autocomplete/$some_key/$some_id?q=keywords' + * + * @param array $element + * The form element to process. Properties used: + * - #autocomplete_route_name: A route to be used as callback URL by the + * autocomplete JavaScript library. + * - #autocomplete_route_parameters: The parameters to be used in + * conjunction with the route name. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * @param array $complete_form + * The complete form structure. + * + * @return array + * The form element. + */ + public static function processAutocomplete(&$element, FormStateInterface $form_state, &$complete_form) { + $access = FALSE; + if (!empty($element['#autocomplete_route_name'])) { + $parameters = isset($element['#autocomplete_route_parameters']) ? $element['#autocomplete_route_parameters'] : array(); + + $path = \Drupal::urlGenerator()->generate($element['#autocomplete_route_name'], $parameters); + $access = \Drupal::service('access_manager')->checkNamedRoute($element['#autocomplete_route_name'], $parameters, \Drupal::currentUser()); + } + if ($access) { + $element['#attributes']['class'][] = 'form-autocomplete'; + $element['#attached']['library'][] = 'core/drupal.autocomplete'; + // Provide a data attribute for the JavaScript behavior to bind to. + $element['#attributes']['data-autocomplete-path'] = $path; + } + + return $element; + } + +} diff --git a/core/lib/Drupal/Core/Render/Element/FormElementInterface.php b/core/lib/Drupal/Core/Render/Element/FormElementInterface.php new file mode 100644 index 00000000000..2bbbde8ecbd --- /dev/null +++ b/core/lib/Drupal/Core/Render/Element/FormElementInterface.php @@ -0,0 +1,46 @@ + array( + array($class, 'preRenderLink'), + ), + ); + } + + /** + * Pre-render callback: Renders a link into #markup. + * + * Doing so during pre_render gives modules a chance to alter the link parts. + * + * @param array $element + * A structured array whose keys form the arguments to l(): + * - #title: The link text to pass as argument to l(). + * - One of the following + * - #route_name and (optionally) a #route_parameters array; The route + * name and route parameters which will be passed into the link + * generator. + * - #href: The system path or URL to pass as argument to l(). + * - #options: (optional) An array of options to pass to l() or the link + * generator. + * + * @return array + * The passed-in element containing a rendered link in '#markup'. + */ + public static function preRenderLink($element) { + // By default, link options to pass to l() are normally set in #options. + $element += array('#options' => array()); + // However, within the scope of renderable elements, #attributes is a valid + // way to specify attributes, too. Take them into account, but do not override + // attributes from #options. + if (isset($element['#attributes'])) { + $element['#options'] += array('attributes' => array()); + $element['#options']['attributes'] += $element['#attributes']; + } + + // This #pre_render callback can be invoked from inside or outside of a Form + // API context, and depending on that, a HTML ID may be already set in + // different locations. #options should have precedence over Form API's #id. + // #attributes have been taken over into #options above already. + if (isset($element['#options']['attributes']['id'])) { + $element['#id'] = $element['#options']['attributes']['id']; + } + elseif (isset($element['#id'])) { + $element['#options']['attributes']['id'] = $element['#id']; + } + + // Conditionally invoke ajax_pre_render_element(), if #ajax is set. + if (isset($element['#ajax']) && !isset($element['#ajax_processed'])) { + // If no HTML ID was found above, automatically create one. + if (!isset($element['#id'])) { + $element['#id'] = $element['#options']['attributes']['id'] = drupal_html_id('ajax-link'); + } + // If #ajax['path] was not specified, use the href as Ajax request URL. + if (!isset($element['#ajax']['path'])) { + $element['#ajax']['path'] = $element['#href']; + $element['#ajax']['options'] = $element['#options']; + } + $element = ajax_pre_render_element($element); + } + + if (isset($element['#route_name'])) { + $element['#route_parameters'] = empty($element['#route_parameters']) ? array() : $element['#route_parameters']; + $element['#markup'] = \Drupal::linkGenerator()->generate($element['#title'], $element['#route_name'], $element['#route_parameters'], $element['#options']); + } + else { + $element['#markup'] = l($element['#title'], $element['#href'], $element['#options']); + } + return $element; + } + +} diff --git a/core/lib/Drupal/Core/Render/Element/MachineName.php b/core/lib/Drupal/Core/Render/Element/MachineName.php new file mode 100644 index 00000000000..6301f3527bb --- /dev/null +++ b/core/lib/Drupal/Core/Render/Element/MachineName.php @@ -0,0 +1,226 @@ + TRUE, + '#default_value' => NULL, + '#required' => TRUE, + '#maxlength' => 64, + '#size' => 60, + '#autocomplete_route_name' => FALSE, + '#process' => array( + array($class, 'processMachineName'), + array($class, 'processAutocomplete'), + array($class, 'processAjaxForm'), + ), + '#element_validate' => array( + array($class, 'validateMachineName'), + ), + '#pre_render' => array( + array($class, 'preRenderTextfield'), + ), + '#theme' => 'input__textfield', + '#theme_wrappers' => array('form_element'), + ); + } + + /** + * {@inheritdoc} + */ + public static function valueCallback(&$element, $input, FormStateInterface $form_state) { + return NULL; + } + + /** + * Processes a machine-readable name form element. + * + * @param array $element + * The form element to process. Properties used: + * - #machine_name: An associative array containing: + * - exists: A callable to invoke for checking whether a submitted machine + * name value already exists. The submitted value is passed as an + * argument. In most cases, an existing API or menu argument loader + * function can be re-used. The callback is only invoked if the + * submitted value differs from the element's #default_value. + * - source: (optional) The #array_parents of the form element containing + * the human-readable name (i.e., as contained in the $form structure) + * to use as source for the machine name. Defaults to array('label'). + * - label: (optional) Text to display as label for the machine name value + * after the human-readable name form element. Defaults to "Machine + * name". + * - replace_pattern: (optional) A regular expression (without delimiters) + * matching disallowed characters in the machine name. Defaults to + * '[^a-z0-9_]+'. + * - replace: (optional) A character to replace disallowed characters in + * the machine name via JavaScript. Defaults to '_' (underscore). When + * using a different character, 'replace_pattern' needs to be set + * accordingly. + * - error: (optional) A custom form error message string to show, if the + * machine name contains disallowed characters. + * - standalone: (optional) Whether the live preview should stay in its + * own form element rather than in the suffix of the source + * element. Defaults to FALSE. + * - #maxlength: (optional) Maximum allowed length of the machine name. + * Defaults to 64. + * - #disabled: (optional) Should be set to TRUE if an existing machine + * name must not be changed after initial creation. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * @param array $complete_form + * The complete form structure. + * + * @return array + * The processed element. + */ + public static function processMachineName(&$element, FormStateInterface $form_state, &$complete_form) { + // We need to pass the langcode to the client. + $language = \Drupal::languageManager()->getCurrentLanguage(); + + // Apply default form element properties. + $element += array( + '#title' => t('Machine-readable name'), + '#description' => t('A unique machine-readable name. Can only contain lowercase letters, numbers, and underscores.'), + '#machine_name' => array(), + '#field_prefix' => '', + '#field_suffix' => '', + '#suffix' => '', + ); + // A form element that only wants to set one #machine_name property (usually + // 'source' only) would leave all other properties undefined, if the defaults + // were defined in hook_element_info(). Therefore, we apply the defaults here. + $element['#machine_name'] += array( + 'source' => array('label'), + 'target' => '#' . $element['#id'], + 'label' => t('Machine name'), + 'replace_pattern' => '[^a-z0-9_]+', + 'replace' => '_', + 'standalone' => FALSE, + 'field_prefix' => $element['#field_prefix'], + 'field_suffix' => $element['#field_suffix'], + ); + + // By default, machine names are restricted to Latin alphanumeric characters. + // So, default to LTR directionality. + if (!isset($element['#attributes'])) { + $element['#attributes'] = array(); + } + $element['#attributes'] += array('dir' => 'ltr'); + + // The source element defaults to array('name'), but may have been overidden. + if (empty($element['#machine_name']['source'])) { + return $element; + } + + // Retrieve the form element containing the human-readable name from the + // complete form in $form_state. By reference, because we may need to append + // a #field_suffix that will hold the live preview. + $key_exists = NULL; + $source = NestedArray::getValue($form_state['complete_form'], $element['#machine_name']['source'], $key_exists); + if (!$key_exists) { + return $element; + } + + $suffix_id = $source['#id'] . '-machine-name-suffix'; + $element['#machine_name']['suffix'] = '#' . $suffix_id; + + if ($element['#machine_name']['standalone']) { + $element['#suffix'] = SafeMarkup::set($element['#suffix'] . '  '); + } + else { + // Append a field suffix to the source form element, which will contain + // the live preview of the machine name. + $source += array('#field_suffix' => ''); + $source['#field_suffix'] = SafeMarkup::set($source['#field_suffix'] . '  '); + + $parents = array_merge($element['#machine_name']['source'], array('#field_suffix')); + NestedArray::setValue($form_state['complete_form'], $parents, $source['#field_suffix']); + } + + $js_settings = array( + 'type' => 'setting', + 'data' => array( + 'machineName' => array( + '#' . $source['#id'] => $element['#machine_name'], + ), + 'langcode' => $language->id, + ), + ); + $element['#attached']['library'][] = 'core/drupal.machine-name'; + $element['#attached']['js'][] = $js_settings; + + return $element; + } + + /** + * Form element validation handler for machine_name elements. + * + * Note that #maxlength is validated by _form_validate() already. + * + * This checks that the submitted value: + * - Does not contain the replacement character only. + * - Does not contain disallowed characters. + * - Is unique; i.e., does not already exist. + * - Does not exceed the maximum length (via #maxlength). + * - Cannot be changed after creation (via #disabled). + */ + public static function validateMachineName(&$element, FormStateInterface $form_state, &$complete_form) { + // Verify that the machine name not only consists of replacement tokens. + if (preg_match('@^' . $element['#machine_name']['replace'] . '+$@', $element['#value'])) { + form_error($element, $form_state, t('The machine-readable name must contain unique characters.')); + } + + // Verify that the machine name contains no disallowed characters. + if (preg_match('@' . $element['#machine_name']['replace_pattern'] . '@', $element['#value'])) { + if (!isset($element['#machine_name']['error'])) { + // Since a hyphen is the most common alternative replacement character, + // a corresponding validation error message is supported here. + if ($element['#machine_name']['replace'] == '-') { + form_error($element, $form_state, t('The machine-readable name must contain only lowercase letters, numbers, and hyphens.')); + } + // Otherwise, we assume the default (underscore). + else { + form_error($element, $form_state, t('The machine-readable name must contain only lowercase letters, numbers, and underscores.')); + } + } + else { + form_error($element, $form_state, $element['#machine_name']['error']); + } + } + + // Verify that the machine name is unique. + if ($element['#default_value'] !== $element['#value']) { + $function = $element['#machine_name']['exists']; + if (call_user_func($function, $element['#value'], $element, $form_state)) { + form_error($element, $form_state, t('The machine-readable name is already in use. It must be unique.')); + } + } + } + +} diff --git a/core/lib/Drupal/Core/Render/Element/RenderElement.php b/core/lib/Drupal/Core/Render/Element/RenderElement.php new file mode 100644 index 00000000000..cf4d4546dd6 --- /dev/null +++ b/core/lib/Drupal/Core/Render/Element/RenderElement.php @@ -0,0 +1,85 @@ + TRUE, + '#size' => 60, + '#maxlength' => 128, + '#autocomplete_route_name' => FALSE, + '#process' => array( + array($class, 'processAutocomplete'), + array($class, 'processAjaxForm'), + array($class, 'processPattern'), + array($class, 'processGroup'), + ), + '#pre_render' => array( + array($class, 'preRenderTextfield'), + array($class, 'preRenderGroup'), + ), + '#theme' => 'input__textfield', + '#theme_wrappers' => array('form_element'), + ); + } + + /** + * {@inheritdoc} + */ + public static function valueCallback(&$element, $input, FormStateInterface $form_state) { + if ($input !== FALSE && $input !== NULL) { + // Equate $input to the form value to ensure it's marked for + // validation. + return str_replace(array("\r", "\n"), '', $input); + } + } + + /** + * Prepares a #type 'textfield' render element for theme_input(). + * + * @param array $element + * An associative array containing the properties of the element. + * Properties used: #title, #value, #description, #size, #maxlength, + * #placeholder, #required, #attributes. + * + * @return array + * The $element with prepared variables ready for theme_input(). + */ + public static function preRenderTextfield($element) { + $element['#attributes']['type'] = 'text'; + Element::setAttributes($element, array('id', 'name', 'value', 'size', 'maxlength', 'placeholder')); + _form_set_attributes($element, array('form-text')); + + return $element; + } + +} diff --git a/core/lib/Drupal/Core/Render/ElementInfo.php b/core/lib/Drupal/Core/Render/ElementInfo.php deleted file mode 100644 index 8d6a3f283c0..00000000000 --- a/core/lib/Drupal/Core/Render/ElementInfo.php +++ /dev/null @@ -1,65 +0,0 @@ -moduleHandler = $module_handler; - } - - /** - * {@inheritdoc} - */ - public function getInfo($type) { - if (!isset($this->elementInfo)) { - $this->elementInfo = $this->buildInfo(); - } - return isset($this->elementInfo[$type]) ? $this->elementInfo[$type] : array(); - } - - /** - * Builds up all element information. - */ - protected function buildInfo() { - $info = $this->moduleHandler->invokeAll('element_info'); - foreach ($info as $element_type => $element) { - $info[$element_type]['#type'] = $element_type; - } - // Allow modules to alter the element type defaults. - $this->moduleHandler->alter('element_info', $info); - - return $info; - } - -} diff --git a/core/lib/Drupal/Core/Render/ElementInfoInterface.php b/core/lib/Drupal/Core/Render/ElementInfoInterface.php deleted file mode 100644 index ad05aebdfb3..00000000000 --- a/core/lib/Drupal/Core/Render/ElementInfoInterface.php +++ /dev/null @@ -1,51 +0,0 @@ -setCacheBackend($cache_backend, 'element_info'); + + parent::__construct('Element', $namespaces, $module_handler, 'Drupal\Core\Render\Annotation\RenderElement'); + } + + /** + * {@inheritdoc} + */ + public function getInfo($type) { + if (!isset($this->elementInfo)) { + $this->elementInfo = $this->buildInfo(); + } + return isset($this->elementInfo[$type]) ? $this->elementInfo[$type] : array(); + } + + /** + * Builds up all element information. + */ + protected function buildInfo() { + // @todo Remove this hook once all elements are converted to plugins in + // https://www.drupal.org/node/2311393. + $info = $this->moduleHandler->invokeAll('element_info'); + + foreach ($this->getDefinitions() as $element_type => $definition) { + $element = $this->createInstance($element_type); + $element_info = $element->getInfo(); + + // If this is element is to be used exclusively in a form, denote that it + // will receive input, and assign the value callback. + if ($element instanceof FormElementInterface) { + $element_info['#input'] = TRUE; + $element_info['#value_callback'] = array($definition['class'], 'valueCallback'); + } + $info[$element_type] = $element_info; + } + foreach ($info as $element_type => $element) { + $info[$element_type]['#type'] = $element_type; + } + // Allow modules to alter the element type defaults. + $this->moduleHandler->alter('element_info', $info); + + return $info; + } + + /** + * {@inheritdoc} + * + * @return \Drupal\Core\Render\Element\ElementInterface + */ + public function createInstance($plugin_id, array $configuration = array()) { + return parent::createInstance($plugin_id, $configuration); + } + +} diff --git a/core/lib/Drupal/Core/Render/ElementInfoManagerInterface.php b/core/lib/Drupal/Core/Render/ElementInfoManagerInterface.php new file mode 100644 index 00000000000..d32acd690f9 --- /dev/null +++ b/core/lib/Drupal/Core/Render/ElementInfoManagerInterface.php @@ -0,0 +1,55 @@ + array('form_pre_render_image_button'), '#theme_wrappers' => array('input__image_button'), ); - $types['textfield'] = array( - '#input' => TRUE, - '#size' => 60, - '#maxlength' => 128, - '#autocomplete_route_name' => FALSE, - '#process' => array('form_process_autocomplete', 'ajax_process_form', 'form_process_pattern', 'form_process_group'), - '#pre_render' => array('form_pre_render_textfield', 'form_pre_render_group'), - '#theme' => 'input__textfield', - '#theme_wrappers' => array('form_element'), - ); $types['tel'] = array( '#input' => TRUE, '#size' => 30, @@ -448,19 +438,6 @@ function system_element_info() { '#theme' => 'input__color', '#theme_wrappers' => array('form_element'), ); - $types['machine_name'] = array( - '#input' => TRUE, - '#default_value' => NULL, - '#required' => TRUE, - '#maxlength' => 64, - '#size' => 60, - '#autocomplete_route_name' => FALSE, - '#process' => array('form_process_machine_name', 'form_process_autocomplete', 'ajax_process_form'), - '#element_validate' => array('form_validate_machine_name'), - '#pre_render' => array('form_pre_render_textfield'), - '#theme' => 'input__textfield', - '#theme_wrappers' => array('form_element'), - ); $types['password'] = array( '#input' => TRUE, '#size' => 60, @@ -586,9 +563,6 @@ function system_element_info() { $types['value'] = array( '#input' => TRUE, ); - $types['link'] = array( - '#pre_render' => array('drupal_pre_render_link'), - ); $types['fieldset'] = array( '#value' => NULL, '#process' => array('form_process_group', 'ajax_process_form'), diff --git a/core/modules/system/theme.api.php b/core/modules/system/theme.api.php index 85eb7283a7a..fabde88462a 100644 --- a/core/modules/system/theme.api.php +++ b/core/modules/system/theme.api.php @@ -163,11 +163,21 @@ * requests and the CSS files used to style that markup. In order to ensure that * a theme can completely customize the markup, module developers should avoid * directly writing HTML markup for pages, blocks, and other user-visible output - * in their modules, and instead return structured "render arrays" (described - * below). Doing this also increases usability, by ensuring that the markup used - * for similar functionality on different areas of the site is the same, which - * gives users fewer user interface patterns to learn. + * in their modules, and instead return structured "render arrays" (see @ref + * arrays below). Doing this also increases usability, by ensuring that the + * markup used for similar functionality on different areas of the site is the + * same, which gives users fewer user interface patterns to learn. * + * For further information on the Theme and Render APIs, see: + * - https://drupal.org/documentation/theme + * - https://drupal.org/node/722174 + * - https://drupal.org/node/933976 + * - https://drupal.org/node/930760 + * + * @todo Check these links. Some are for Drupal 7, and might need updates for + * Drupal 8. + * + * @section arrays Render arrays * The core structure of the Render API is the render array, which is a * hierarchical associative array containing data to be rendered and properties * describing how the data should be rendered. A render array that is returned @@ -194,11 +204,8 @@ * - #type: Specifies that the array contains data and options for a particular * type of "render element" (examples: 'form', for an HTML form; 'textfield', * 'submit', and other HTML form element types; 'table', for a table with - * rows, columns, and headers). Modules define render elements by implementing - * hook_element_info(), which specifies the properties that are used in render - * arrays to provide the data and options, and default values for these - * properties. Look through implementations of hook_element_info() to discover - * what render elements are available. + * rows, columns, and headers). See @ref elements below for more on render + * element types. * - #theme: Specifies that the array contains data to be themed by a particular * theme hook. Modules define theme hooks by implementing hook_theme(), which * specifies the input "variables" used to provide data and options; if a @@ -214,15 +221,29 @@ * normally preferable to use #theme or #type instead, so that the theme can * customize the markup. * - * For further information on the Theme and Render APIs, see: - * - https://drupal.org/documentation/theme - * - https://drupal.org/developing/modules/8 - * - https://drupal.org/node/722174 - * - https://drupal.org/node/933976 - * - https://drupal.org/node/930760 + * @section elements Render elements + * Render elements are defined by Drupal core and modules. The primary way to + * define a render element is to create a render element plugin. There are + * two types of render element plugins: + * - Generic elements: Generic render element plugins implement + * \Drupal\Core\Render\Element\ElementInterface, are annotated with + * \Drupal\Core\Render\Annotation\RenderElement annotation, go in plugin + * namespace Element, and generally extend the + * \Drupal\Core\Render\Element\RenderElement base class. + * - Form input elements: Render elements representing form input elements + * implement \Drupal\Core\Render\Element\FormElementInterface, are annotated + * with \Drupal\Core\Render\Annotation\FormElement annotation, go in plugin + * namespace Element, and generally extend the + * \Drupal\Core\Render\Element\FormElement base class. + * See the @link plugin_api Plugin API topic @endlink for general information + * on plugins, and look for classes with the RenderElement or FormElement + * annotation to discover what render elements are available. + * + * Modules can also currently define render elements by implementing + * hook_element_info(), although defining a plugin is preferred. + * properties. Look through implementations of hook_element_info() to discover + * elements defined this way. * - * @todo Check these links. Some are for Drupal 7, and might need updates for - * Drupal 8. * @see themeable * * @} diff --git a/core/tests/Drupal/Tests/Core/Render/ElementInfoManagerTest.php b/core/tests/Drupal/Tests/Core/Render/ElementInfoManagerTest.php new file mode 100644 index 00000000000..fa9cbb3a23f --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Render/ElementInfoManagerTest.php @@ -0,0 +1,197 @@ +cache = $this->getMock('Drupal\Core\Cache\CacheBackendInterface'); + $this->moduleHandler = $this->getMock('Drupal\Core\Extension\ModuleHandlerInterface'); + + $this->elementInfo = new ElementInfoManager(new \ArrayObject(), $this->cache, $this->moduleHandler); + } + + /** + * Tests the getInfo method. + * + * @covers ::getInfo + * @covers ::buildInfo + * + * @dataProvider providerTestGetInfo + */ + public function testGetInfo($type, $expected_info, $element_info, callable $alter_callback = NULL) { + $this->moduleHandler->expects($this->once()) + ->method('invokeAll') + ->with('element_info') + ->will($this->returnValue($element_info)); + $this->moduleHandler->expects($this->once()) + ->method('alter') + ->with('element_info', $this->anything()) + ->will($this->returnCallback($alter_callback ?: function($info) { + return $info; + })); + + $this->assertEquals($expected_info, $this->elementInfo->getInfo($type)); + } + + /** + * Provides tests data for getInfo. + * + * @return array + */ + public function providerTestGetInfo() { + $data = array(); + // Provide an element and expect it is returned. + $data[] = array( + 'page', + array( + '#type' => 'page', + '#show_messages' => TRUE, + '#theme' => 'page', + ), + array('page' => array( + '#show_messages' => TRUE, + '#theme' => 'page', + )), + ); + // Provide an element but request an non existent one. + $data[] = array( + 'form', + array( + ), + array('page' => array( + '#show_messages' => TRUE, + '#theme' => 'page', + )), + ); + // Provide an element and alter it to ensure it is altered. + $data[] = array( + 'page', + array( + '#type' => 'page', + '#show_messages' => TRUE, + '#theme' => 'page', + '#number' => 597219, + ), + array('page' => array( + '#show_messages' => TRUE, + '#theme' => 'page', + )), + function ($alter_name, array &$info) { + $info['page']['#number'] = 597219; + } + ); + return $data; + } + + /** + * Tests the getInfo() method when render element plugins are used. + * + * @covers ::getInfo + * @covers ::buildInfo + * + * @dataProvider providerTestGetInfoElementPlugin + */ + public function testGetInfoElementPlugin($plugin_class, $expected_info) { + $this->moduleHandler->expects($this->once()) + ->method('invokeAll') + ->with('element_info') + ->willReturn(array()); + $this->moduleHandler->expects($this->once()) + ->method('alter') + ->with('element_info', $this->anything()) + ->will($this->returnArgument(0)); + + $plugin = $this->getMock($plugin_class); + $plugin->expects($this->once()) + ->method('getInfo') + ->willReturn(array( + '#show_messages' => TRUE, + '#theme' => 'page', + )); + + $element_info = $this->getMockBuilder('Drupal\Core\Render\ElementInfoManager') + ->setConstructorArgs(array(new \ArrayObject(), $this->cache, $this->moduleHandler)) + ->setMethods(array('getDefinitions', 'createInstance')) + ->getMock(); + $element_info->expects($this->once()) + ->method('createInstance') + ->with('page') + ->willReturn($plugin); + $element_info->expects($this->once()) + ->method('getDefinitions') + ->willReturn(array( + 'page' => array('class' => 'TestElementPlugin'), + )); + + $this->assertEquals($expected_info, $element_info->getInfo('page')); + } + + /** + * Provides tests data for testGetInfoElementPlugin(). + * + * @return array + */ + public function providerTestGetInfoElementPlugin() { + $data = array(); + $data[] = array( + 'Drupal\Core\Render\Element\ElementInterface', + array( + '#type' => 'page', + '#show_messages' => TRUE, + '#theme' => 'page', + ), + ); + + $data[] = array( + 'Drupal\Core\Render\Element\FormElementInterface', + array( + '#type' => 'page', + '#show_messages' => TRUE, + '#theme' => 'page', + '#input' => TRUE, + '#value_callback' => array('TestElementPlugin', 'valueCallback'), + ), + ); + return $data; + } + +} diff --git a/core/tests/Drupal/Tests/Core/Render/ElementInfoTest.php b/core/tests/Drupal/Tests/Core/Render/ElementInfoTest.php deleted file mode 100644 index 86878f4322a..00000000000 --- a/core/tests/Drupal/Tests/Core/Render/ElementInfoTest.php +++ /dev/null @@ -1,118 +0,0 @@ -moduleHandler = $this->getMock('Drupal\Core\Extension\ModuleHandlerInterface'); - - $this->elementInfo = new ElementInfo($this->moduleHandler); - } - - /** - * Tests the getInfo method. - * - * @covers ::getInfo - * @covers ::buildInfo - * - * @dataProvider providerTestGetInfo - */ - public function testGetInfo($type, $expected_info, $element_info, callable $alter_callback = NULL) { - $this->moduleHandler->expects($this->once()) - ->method('invokeAll') - ->with('element_info') - ->will($this->returnValue($element_info)); - $this->moduleHandler->expects($this->once()) - ->method('alter') - ->with('element_info', $this->anything()) - ->will($this->returnCallback($alter_callback ?: function($info) { - return $info; - })); - - $this->assertEquals($expected_info, $this->elementInfo->getInfo($type)); - } - - /** - * Provides tests data for getInfo. - * - * @return array - */ - public function providerTestGetInfo() { - $data = array(); - // Provide an element and expect it is returned. - $data[] = array( - 'page', - array( - '#type' => 'page', - '#show_messages' => TRUE, - '#theme' => 'page', - ), - array('page' => array( - '#show_messages' => TRUE, - '#theme' => 'page', - )), - ); - // Provide an element but request an non existent one. - $data[] = array( - 'form', - array( - ), - array('page' => array( - '#show_messages' => TRUE, - '#theme' => 'page', - )), - ); - // Provide an element and alter it to ensure it is altered. - $data[] = array( - 'page', - array( - '#type' => 'page', - '#show_messages' => TRUE, - '#theme' => 'page', - '#number' => 597219, - ), - array('page' => array( - '#show_messages' => TRUE, - '#theme' => 'page', - )), - function ($alter_name, array &$info) { - $info['page']['#number'] = 597219; - } - ); - return $data; - } - -}