'', 'placeholder_title' => '', ] + parent::defaultSettings(); } /** * Gets the URI without the 'internal:' or 'entity:' scheme. * * The following two forms of URIs are transformed: * - 'entity:' URIs: to entity autocomplete ("label (entity id)") strings; * - 'internal:' URIs: the scheme is stripped. * * This method is the inverse of ::getUserEnteredStringAsUri(). * * @param string $uri * The URI to get the displayable string for. * * @return string * * @see static::getUserEnteredStringAsUri() */ protected static function getUriAsDisplayableString($uri) { $scheme = parse_url($uri, PHP_URL_SCHEME); // By default, the displayable string is the URI. $displayable_string = $uri; // A different displayable string may be chosen in case of the 'internal:' // or 'entity:' built-in schemes. if ($scheme === 'internal') { $uri_reference = explode(':', $uri, 2)[1]; // @todo '' is valid input for BC reasons, may be removed by // https://www.drupal.org/node/2421941 $path = parse_url($uri, PHP_URL_PATH); if ($path === '/') { $uri_reference = '' . substr($uri_reference, 1); } $displayable_string = $uri_reference; } elseif ($scheme === 'entity') { list($entity_type, $entity_id) = explode('/', substr($uri, 7), 2); // Show the 'entity:' URI as the entity autocomplete would. // @todo Support entity types other than 'node'. Will be fixed in // https://www.drupal.org/node/2423093. if ($entity_type == 'node' && $entity = \Drupal::entityTypeManager()->getStorage($entity_type)->load($entity_id)) { $displayable_string = EntityAutocomplete::getEntityLabels([$entity]); } } return $displayable_string; } /** * Gets the user-entered string as a URI. * * The following two forms of input are mapped to URIs: * - entity autocomplete ("label (entity id)") strings: to 'entity:' URIs; * - strings without a detectable scheme: to 'internal:' URIs. * * This method is the inverse of ::getUriAsDisplayableString(). * * @param string $string * The user-entered string. * * @return string * The URI, if a non-empty $uri was passed. * * @see static::getUriAsDisplayableString() */ protected static function getUserEnteredStringAsUri($string) { // By default, assume the entered string is an URI. $uri = $string; // Detect entity autocomplete string, map to 'entity:' URI. $entity_id = EntityAutocomplete::extractEntityIdFromAutocompleteInput($string); if ($entity_id !== NULL) { // @todo Support entity types other than 'node'. Will be fixed in // https://www.drupal.org/node/2423093. $uri = 'entity:node/' . $entity_id; } // Detect a schemeless string, map to 'internal:' URI. elseif (!empty($string) && parse_url($string, PHP_URL_SCHEME) === NULL) { // @todo '' is valid input for BC reasons, may be removed by // https://www.drupal.org/node/2421941 // - '' -> '/' // - '#foo' -> '/#foo' if (strpos($string, '') === 0) { $string = '/' . substr($string, strlen('')); } $uri = 'internal:' . $string; } return $uri; } /** * Form element validation handler for the 'uri' element. * * Disallows saving inaccessible or untrusted URLs. */ public static function validateUriElement($element, FormStateInterface $form_state, $form) { $uri = static::getUserEnteredStringAsUri($element['#value']); $form_state->setValueForElement($element, $uri); // If getUserEnteredStringAsUri() mapped the entered value to a 'internal:' // URI , ensure the raw value begins with '/', '?' or '#'. // @todo '' is valid input for BC reasons, may be removed by // https://www.drupal.org/node/2421941 if (parse_url($uri, PHP_URL_SCHEME) === 'internal' && !in_array($element['#value'][0], ['/', '?', '#'], TRUE) && substr($element['#value'], 0, 7) !== '') { $form_state->setError($element, t('Manually entered paths should start with /, ? or #.')); return; } } /** * Form element validation handler for the 'title' element. * * Conditionally requires the link title if a URL value was filled in. */ public static function validateTitleElement(&$element, FormStateInterface $form_state, $form) { if ($element['uri']['#value'] !== '' && $element['title']['#value'] === '') { $element['title']['#required'] = TRUE; // We expect the field name placeholder value to be wrapped in t() here, // so it won't be escaped again as it's already marked safe. $form_state->setError($element['title'], t('@name field is required.', ['@name' => $element['title']['#title']])); } } /** * {@inheritdoc} */ public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) { /** @var \Drupal\link\LinkItemInterface $item */ $item = $items[$delta]; $element['uri'] = [ '#type' => 'url', '#title' => $this->t('URL'), '#placeholder' => $this->getSetting('placeholder_url'), // The current field value could have been entered by a different user. // However, if it is inaccessible to the current user, do not display it // to them. '#default_value' => (!$item->isEmpty() && (\Drupal::currentUser()->hasPermission('link to any page') || $item->getUrl()->access())) ? static::getUriAsDisplayableString($item->uri) : NULL, '#element_validate' => [[get_called_class(), 'validateUriElement']], '#maxlength' => 2048, '#required' => $element['#required'], '#link_type' => $this->getFieldSetting('link_type'), ]; // If the field is configured to support internal links, it cannot use the // 'url' form element and we have to do the validation ourselves. if ($this->supportsInternalLinks()) { $element['uri']['#type'] = 'entity_autocomplete'; // @todo The user should be able to select an entity type. Will be fixed // in https://www.drupal.org/node/2423093. $element['uri']['#target_type'] = 'node'; // Disable autocompletion when the first character is '/', '#' or '?'. $element['uri']['#attributes']['data-autocomplete-first-character-blacklist'] = '/#?'; // The link widget is doing its own processing in // static::getUriAsDisplayableString(). $element['uri']['#process_default_value'] = FALSE; } // If the field is configured to allow only internal links, add a useful // element prefix and description. if (!$this->supportsExternalLinks()) { $element['uri']['#field_prefix'] = rtrim(\Drupal::url('', [], ['absolute' => TRUE]), '/'); $element['uri']['#description'] = $this->t('This must be an internal path such as %add-node. You can also start typing the title of a piece of content to select it. Enter %front to link to the front page.', ['%add-node' => '/node/add', '%front' => '']); } // If the field is configured to allow both internal and external links, // show a useful description. elseif ($this->supportsExternalLinks() && $this->supportsInternalLinks()) { $element['uri']['#description'] = $this->t('Start typing the title of a piece of content to select it. You can also enter an internal path such as %add-node or an external URL such as %url. Enter %front to link to the front page.', ['%front' => '', '%add-node' => '/node/add', '%url' => 'http://example.com']); } // If the field is configured to allow only external links, show a useful // description. elseif ($this->supportsExternalLinks() && !$this->supportsInternalLinks()) { $element['uri']['#description'] = $this->t('This must be an external URL such as %url.', ['%url' => 'http://example.com']); } $element['title'] = [ '#type' => 'textfield', '#title' => $this->t('Link text'), '#placeholder' => $this->getSetting('placeholder_title'), '#default_value' => isset($items[$delta]->title) ? $items[$delta]->title : NULL, '#maxlength' => 255, '#access' => $this->getFieldSetting('title') != DRUPAL_DISABLED, ]; // Post-process the title field to make it conditionally required if URL is // non-empty. Omit the validation on the field edit form, since the field // settings cannot be saved otherwise. if (!$this->isDefaultValueWidget($form_state) && $this->getFieldSetting('title') == DRUPAL_REQUIRED) { $element['#element_validate'][] = [get_called_class(), 'validateTitleElement']; } // Exposing the attributes array in the widget is left for alternate and more // advanced field widgets. $element['attributes'] = [ '#type' => 'value', '#tree' => TRUE, '#value' => !empty($items[$delta]->options['attributes']) ? $items[$delta]->options['attributes'] : [], '#attributes' => ['class' => ['link-field-widget-attributes']], ]; // If cardinality is 1, ensure a proper label is output for the field. if ($this->fieldDefinition->getFieldStorageDefinition()->getCardinality() == 1) { // If the link title is disabled, use the field definition label as the // title of the 'uri' element. if ($this->getFieldSetting('title') == DRUPAL_DISABLED) { $element['uri']['#title'] = $element['#title']; } // Otherwise wrap everything in a details element. else { $element += [ '#type' => 'fieldset', ]; } } return $element; } /** * Indicates enabled support for link to routes. * * @return bool * Returns TRUE if the LinkItem field is configured to support links to * routes, otherwise FALSE. */ protected function supportsInternalLinks() { $link_type = $this->getFieldSetting('link_type'); return (bool) ($link_type & LinkItemInterface::LINK_INTERNAL); } /** * Indicates enabled support for link to external URLs. * * @return bool * Returns TRUE if the LinkItem field is configured to support links to * external URLs, otherwise FALSE. */ protected function supportsExternalLinks() { $link_type = $this->getFieldSetting('link_type'); return (bool) ($link_type & LinkItemInterface::LINK_EXTERNAL); } /** * {@inheritdoc} */ public function settingsForm(array $form, FormStateInterface $form_state) { $elements = parent::settingsForm($form, $form_state); $elements['placeholder_url'] = [ '#type' => 'textfield', '#title' => $this->t('Placeholder for URL'), '#default_value' => $this->getSetting('placeholder_url'), '#description' => $this->t('Text that will be shown inside the field until a value is entered. This hint is usually a sample value or a brief description of the expected format.'), ]; $elements['placeholder_title'] = [ '#type' => 'textfield', '#title' => $this->t('Placeholder for link text'), '#default_value' => $this->getSetting('placeholder_title'), '#description' => $this->t('Text that will be shown inside the field until a value is entered. This hint is usually a sample value or a brief description of the expected format.'), '#states' => [ 'invisible' => [ ':input[name="instance[settings][title]"]' => ['value' => DRUPAL_DISABLED], ], ], ]; return $elements; } /** * {@inheritdoc} */ public function settingsSummary() { $summary = []; $placeholder_title = $this->getSetting('placeholder_title'); $placeholder_url = $this->getSetting('placeholder_url'); if (empty($placeholder_title) && empty($placeholder_url)) { $summary[] = $this->t('No placeholders'); } else { if (!empty($placeholder_title)) { $summary[] = $this->t('Title placeholder: @placeholder_title', ['@placeholder_title' => $placeholder_title]); } if (!empty($placeholder_url)) { $summary[] = $this->t('URL placeholder: @placeholder_url', ['@placeholder_url' => $placeholder_url]); } } return $summary; } /** * {@inheritdoc} */ public function massageFormValues(array $values, array $form, FormStateInterface $form_state) { foreach ($values as &$value) { $value['uri'] = static::getUserEnteredStringAsUri($value['uri']); $value += ['options' => []]; } return $values; } /** * {@inheritdoc} * * Override the '%uri' message parameter, to ensure that 'internal:' URIs * show a validation error message that doesn't mention that scheme. */ public function flagErrors(FieldItemListInterface $items, ConstraintViolationListInterface $violations, array $form, FormStateInterface $form_state) { /** @var \Symfony\Component\Validator\ConstraintViolationInterface $violation */ foreach ($violations as $offset => $violation) { $parameters = $violation->getParameters(); if (isset($parameters['@uri'])) { $parameters['@uri'] = static::getUriAsDisplayableString($parameters['@uri']); $violations->set($offset, new ConstraintViolation( $this->t($violation->getMessageTemplate(), $parameters), $violation->getMessageTemplate(), $parameters, $violation->getRoot(), $violation->getPropertyPath(), $violation->getInvalidValue(), $violation->getPlural(), $violation->getCode() )); } } parent::flagErrors($items, $violations, $form, $form_state); } }