Commit 305d9a27 authored by bojanz's avatar bojanz Committed by bojanz

Issue #2689089 by bojanz: Define a form element type "address"

parent 725375be
<?php
namespace Drupal\address\Element;
use CommerceGuys\Addressing\AddressFormat\AddressField;
use CommerceGuys\Addressing\AddressFormat\AddressFormat;
use CommerceGuys\Addressing\AddressFormat\AddressFormatHelper;
use CommerceGuys\Addressing\LocaleHelper;
use Drupal\address\FieldHelper;
use Drupal\address\LabelHelper;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\NestedArray;
use Drupal\Component\Utility\SortArray;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\Render\Element\FormElement;
/**
* Provides an address form element.
*
* Usage example:
* @code
* $form['address'] = [
* '#type' => 'address',
* '#title' => $this->t('Address'),
* '#default_value' => [
* 'given_name' => 'John',
* 'family_name' => 'Smith',
* 'organization' => 'Google Inc.',
* 'address_line1' => '1098 Alta Ave',
* 'postal_code' => '94043',
* 'locality' => 'Mountain View',
* 'administrative_area' => 'CA',
* 'country_code' => 'US',
* 'langcode' => 'en',
* ],
* '#available_countries' => ['DE', 'FR'],
* ];
* @endcode
*
* @FormElement("address")
*/
class Address extends FormElement {
/**
* {@inheritdoc}
*/
public function getInfo() {
$class = get_class($this);
return [
// List of country codes. If empty, all countries will be available.
'#available_countries' => [],
// List of AddressField constants. If empty, all fields will be used.
'#used_fields' => [],
'#input' => TRUE,
'#multiple' => FALSE,
'#process' => [
[$class, 'processAddress'],
[$class, 'processGroup'],
],
'#pre_render' => [
[$class, 'groupElements'],
[$class, 'preRenderGroup'],
],
'#after_build' => [
[$class, 'clearValues'],
],
'#attached' => [
'library' => ['address/form'],
],
'#theme_wrappers' => ['container'],
];
}
/**
* {@inheritdoc}
*/
public static function valueCallback(&$element, $input, FormStateInterface $form_state) {
if (is_array($input)) {
return $input;
}
else {
if (!is_array($element['#default_value'])) {
$element['#default_value'] = [];
}
// Initialize properties.
$properties = [
'given_name', 'additional_name', 'family_name', 'organization',
'address_line1', 'address_line2', 'postal_code', 'sorting_code',
'dependent_locality', 'locality', 'administrative_area',
'country_code', 'langcode',
];
foreach ($properties as $property) {
if (!isset($element['#default_value'][$property])) {
$element['#default_value'][$property] = NULL;
}
}
return $element['#default_value'];
}
}
/**
* Processes the address form element.
*
* @param array $element
* The form element to process.
* @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.
*
* @throws \InvalidArgumentException
* Thrown when #available_countries or #used_fields is malformed.
*/
public static function processAddress(&$element, FormStateInterface $form_state, &$complete_form) {
if (isset($element['#available_countries']) && !is_array($element['#available_countries'])) {
throw new \InvalidArgumentException('The #available_countries property must be an array.');
}
if (isset($element['#used_fields']) && !is_array($element['#used_fields'])) {
throw new \InvalidArgumentException('The #used_fields property must be an array.');
}
$id_prefix = implode('-', $element['#parents']);
$wrapper_id = Html::getUniqueId($id_prefix . '-ajax-wrapper');
$full_country_list = \Drupal::service('address.country_repository')->getList();
$country_list = $full_country_list;
if (!empty($element['#available_countries'])) {
$available_countries = $element['#available_countries'];
$available_countries = array_combine($available_countries, $available_countries);
$country_list = array_intersect_key($country_list, $available_countries);
}
$value = $element['#value'];
if (empty($value['country_code']) && $element['#required']) {
// Fallback to the first country in the list if the default country
// is empty even though the field is required.
$value['country_code'] = key($country_list);
}
if (!empty($value['country_code']) && !isset($country_list[$value['country_code']])) {
// This item's country is no longer available. Add it back to the top
// of the list to ensure all data is displayed properly. The validator
// can then prevent the save and tell the user to change the country.
$missing_element = [
$value['country_code'] => $full_country_list[$value['country_code']],
];
$country_list = $missing_element + $country_list;
}
$element += [
'#prefix' => '<div id="' . $wrapper_id . '">',
'#suffix' => '</div>',
// Pass the id along to other methods.
'#wrapper_id' => $wrapper_id,
];
$element['langcode'] = [
'#type' => 'hidden',
'#value' => $value['langcode'],
];
// Hide the country dropdown when there is only one possible value.
if (count($country_list) == 1 && $element['#required']) {
$element['country_code'] = [
'#type' => 'hidden',
'#value' => key($available_countries),
];
}
else {
$element['country_code'] = [
'#type' => 'select',
'#title' => t('Country'),
'#options' => $country_list,
'#default_value' => $value['country_code'],
'#required' => $element['#required'],
'#limit_validation_errors' => [],
'#ajax' => [
'callback' => [get_called_class(), 'ajaxRefresh'],
'wrapper' => $wrapper_id,
],
'#attributes' => [
'class' => ['country'],
'autocomplete' => 'country',
],
'#weight' => -100,
];
if (!$element['#required']) {
$element['country_code']['#empty_value'] = '';
}
}
if (!empty($value['country_code'])) {
$element = static::addressElements($element, $value);
}
return $element;
}
/**
* Builds the format-specific address elements.
*
* @param array $element
* The existing form element array.
* @param array $value
* The address value, in $property_name => $value format.
*
* @return array
* The modified form element array containing the format specific elements.
*/
protected static function addressElements(array $element, array $value) {
$size_attributes = [
AddressField::ADMINISTRATIVE_AREA => 30,
AddressField::LOCALITY => 30,
AddressField::DEPENDENT_LOCALITY => 30,
AddressField::POSTAL_CODE => 10,
AddressField::SORTING_CODE => 10,
AddressField::GIVEN_NAME => 25,
AddressField::ADDITIONAL_NAME => 25,
AddressField::FAMILY_NAME => 25,
];
/** @var \CommerceGuys\Addressing\AddressFormat\AddressFormat $address_format */
$address_format = \Drupal::service('address.address_format_repository')->get($value['country_code']);
$required_fields = $address_format->getRequiredFields();
$labels = LabelHelper::getFieldLabels($address_format);
$locale = \Drupal::languageManager()->getConfigOverrideLanguage()->getId();
if (LocaleHelper::match($address_format->getLocale(), $locale)) {
$format_string = $address_format->getLocalFormat();
} else {
$format_string = $address_format->getFormat();
}
$grouped_fields = AddressFormatHelper::getGroupedFields($format_string);
foreach ($grouped_fields as $line_index => $line_fields) {
if (count($line_fields) > 1) {
// Used by the #pre_render callback to group fields inline.
$element['container' . $line_index] = [
'#type' => 'container',
'#attributes' => [
'class' => ['address-container-inline'],
],
];
}
foreach ($line_fields as $field_index => $field) {
$property = FieldHelper::getPropertyName($field);
$class = str_replace('_', '-', $property);
$element[$property] = [
'#type' => 'textfield',
'#title' => $labels[$field],
'#default_value' => isset($value[$property]) ? $value[$property] : '',
'#required' => in_array($field, $required_fields),
'#size' => isset($size_attributes[$field]) ? $size_attributes[$field] : 60,
'#attributes' => [
'class' => [$class],
'autocomplete' => FieldHelper::getAutocompleteAttribute($field),
],
];
if (count($line_fields) > 1) {
$element[$property]['#group'] = $line_index;
}
}
}
// Hide the label for the second address line.
if (isset($element['address_line2'])) {
$element['address_line2']['#title_display'] = 'invisible';
}
// Hide unused fields.
if (!empty($element['#used_fields'])) {
$used_fields = $element['#used_fields'];
$unused_fields = array_diff(AddressField::getAll(), $used_fields);
foreach ($unused_fields as $field) {
$property = FieldHelper::getPropertyName($field);
$element[$property]['#access'] = FALSE;
}
}
// Add predefined options to the created subdivision elements.
$element = static::processSubdivisionElements($element, $value, $address_format);
return $element;
}
/**
* Processes the subdivision elements, adding predefined values where found.
*
* @param array $element
* The existing form element array.
* @param array $value
* The address value, in $property_name => $value format.
* @param \CommerceGuys\Addressing\AddressFormat\AddressFormat $address_format
* The address format.
*
* @return array
* The processed form element array.
*/
protected static function processSubdivisionElements(array $element, array $value, AddressFormat $address_format) {
$depth = $address_format->getSubdivisionDepth();
if ($depth === 0) {
// No predefined data found.
return $element;
}
$subdivision_properties = [];
foreach ($address_format->getUsedSubdivisionFields() as $field) {
$subdivision_properties[] = FieldHelper::getPropertyName($field);
}
// Load and insert the subdivisions for each parent id.
$current_depth = 1;
$parents = [];
foreach ($subdivision_properties as $index => $property) {
if (!isset($element[$property]) || !Element::isVisibleElement($element[$property])) {
break;
}
$parent_property = $index ? $subdivision_properties[$index - 1] : 'country_code';
if ($parent_property && empty($value[$parent_property])) {
break;
}
$parents[] = $value[$parent_property];
$subdivisions = \Drupal::service('address.subdivision_repository')->getList($parents);
if (empty($subdivisions)) {
break;
}
$element[$property]['#type'] = 'select';
$element[$property]['#options'] = $subdivisions;
$element[$property]['#empty_value'] = '';
unset($element[$property]['#size']);
if ($current_depth < $depth) {
$element[$property]['#ajax'] = [
'callback' => [get_called_class(), 'ajaxRefresh'],
'wrapper' => $element['#wrapper_id'],
];
}
$current_depth++;
}
return $element;
}
/**
* Groups elements with the same #group so that they can be inlined.
*/
public static function groupElements(array $element) {
$sort = [];
foreach (Element::getVisibleChildren($element) as $key) {
if (isset($element[$key]['#group'])) {
// Copy the element to the container and remove the original.
$group_index = $element[$key]['#group'];
$container_key = 'container' . $group_index;
$element[$container_key][$key] = $element[$key];
unset($element[$key]);
// Mark the container for sorting.
if (!in_array($container_key, $sort)) {
$sort[] = $container_key;
}
}
}
// Sort the moved elements, so that their #weight stays respected.
foreach ($sort as $key) {
uasort($element[$key], [SortArray::class, 'sortByWeightProperty']);
}
return $element;
}
/**
* Ajax callback.
*/
public static function ajaxRefresh(array $form, FormStateInterface $form_state) {
$country_element = $form_state->getTriggeringElement();
$address_element = NestedArray::getValue($form, array_slice($country_element['#array_parents'], 0, -1));
return $address_element;
}
/**
* Clears the country-specific form values when the country changes.
*
* Implemented as an #after_build callback because #after_build runs before
* validation, allowing the values to be cleared early enough to prevent the
* "Illegal choice" error.
*/
public static function clearValues(array $element, FormStateInterface $form_state) {
$triggering_element = $form_state->getTriggeringElement();
if (!$triggering_element) {
return $element;
}
$triggering_element_name = end($triggering_element['#parents']);
if ($triggering_element_name == 'country_code') {
$keys = [
'dependent_locality', 'locality', 'administrative_area',
'postal_code', 'sorting_code',
];
$input = &$form_state->getUserInput();
foreach ($keys as $key) {
$parents = array_merge($element['#parents'], [$key]);
NestedArray::setValue($input, $parents, '');
$element[$key]['#value'] = '';
}
}
return $element;
}
}
......@@ -3,24 +3,15 @@
namespace Drupal\address\Plugin\Field\FieldWidget;
use CommerceGuys\Addressing\AddressFormat\AddressField;
use CommerceGuys\Addressing\AddressFormat\AddressFormat;
use CommerceGuys\Addressing\AddressFormat\AddressFormatHelper;
use CommerceGuys\Addressing\AddressFormat\AddressFormatRepositoryInterface;
use CommerceGuys\Addressing\Country\CountryRepositoryInterface;
use CommerceGuys\Addressing\LocaleHelper;
use CommerceGuys\Addressing\Subdivision\SubdivisionRepositoryInterface;
use Drupal\address\Event\AddressEvents;
use Drupal\address\Event\InitialValuesEvent;
use Drupal\address\FieldHelper;
use Drupal\address\LabelHelper;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\WidgetBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Render\Element;
use Symfony\Component\DependencyInjection\ContainerInterface;
......@@ -40,13 +31,6 @@ use Symfony\Component\Validator\ConstraintViolationInterface;
*/
class AddressDefaultWidget extends WidgetBase implements ContainerFactoryPluginInterface {
/**
* The address format repository.
*
* @var \CommerceGuys\Addressing\AddressFormat\AddressFormatRepositoryInterface
*/
protected $addressFormatRepository;
/**
* The country repository.
*
......@@ -54,13 +38,6 @@ class AddressDefaultWidget extends WidgetBase implements ContainerFactoryPluginI
*/
protected $countryRepository;
/**
* The subdivision repository.
*
* @var \CommerceGuys\Addressing\Subdivision\SubdivisionRepositoryInterface
*/
protected $subdivisionRepository;
/**
* The event dispatcher.
*
......@@ -75,13 +52,6 @@ class AddressDefaultWidget extends WidgetBase implements ContainerFactoryPluginI
*/
protected $configFactory;
/**
* The language manager.
*
* @var \Drupal\Core\Language\LanguageManagerInterface
*/
protected $languageManager;
/**
* The size attributes for fields likely to be inlined.
*
......@@ -111,28 +81,19 @@ class AddressDefaultWidget extends WidgetBase implements ContainerFactoryPluginI
* The widget settings.
* @param array $third_party_settings
* Any third party settings.
* @param \CommerceGuys\Addressing\AddressFormat\AddressFormatRepositoryInterface $address_format_repository
* The address format repository.
* @param \CommerceGuys\Addressing\Country\CountryRepositoryInterface $country_repository
* The country repository.
* @param \CommerceGuys\Addressing\Subdivision\SubdivisionRepositoryInterface $subdivision_repository
* The subdivision repository.
* @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher
* The event dispatcher.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
* The language manager.
*/
public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, AddressFormatRepositoryInterface $address_format_repository, CountryRepositoryInterface $country_repository, SubdivisionRepositoryInterface $subdivision_repository, EventDispatcherInterface $event_dispatcher, ConfigFactoryInterface $config_factory, LanguageManagerInterface $language_manager) {
public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, CountryRepositoryInterface $country_repository, EventDispatcherInterface $event_dispatcher, ConfigFactoryInterface $config_factory) {
parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings);
$this->addressFormatRepository = $address_format_repository;
$this->countryRepository = $country_repository;
$this->subdivisionRepository = $subdivision_repository;
$this->eventDispatcher = $event_dispatcher;
$this->configFactory = $config_factory;
$this->languageManager = $language_manager;
}
/**
......@@ -146,12 +107,9 @@ class AddressDefaultWidget extends WidgetBase implements ContainerFactoryPluginI
$configuration['field_definition'],
$configuration['settings'],
$configuration['third_party_settings'],
$container->get('address.address_format_repository'),
$container->get('address.country_repository'),
$container->get('address.subdivision_repository'),
$container->get('event_dispatcher'),
$container->get('config.factory'),
$container->get('language_manager')
$container->get('config.factory')
);
}
......@@ -209,25 +167,15 @@ class AddressDefaultWidget extends WidgetBase implements ContainerFactoryPluginI
*
* @see address_form_field_config_edit_form_alter()
*
* @param array $country_list
* The filtered country list, in the country_code => name format.
*
* @return array
* The initial values, keyed by property.
*/
protected function getInitialValues(array $country_list) {
protected function getInitialValues() {
$default_country = $this->getSetting('default_country');
// Resolve the special site_default option.
if ($default_country == 'site_default') {
$default_country = $this->configFactory->get('system.date')->get('country.default');
}
// Fallback to the first country in the list if the default country is not
// available, or is empty even though the field is required.
$not_available = $default_country && !isset($country_list[$default_country]);
$empty_but_required = empty($default_country) && $this->fieldDefinition->isRequired();
if ($not_available || $empty_but_required) {
$default_country = key($country_list);
}
$initial_values = [
'country_code' => $default_country,
......@@ -255,98 +203,25 @@ class AddressDefaultWidget extends WidgetBase implements ContainerFactoryPluginI
* {@inheritdoc}
*/
public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
$field_name = $this->fieldDefinition->getName();
$id_prefix = implode('-', array_merge($element['#field_parents'], [$field_name]));
$wrapper_id = Html::getUniqueId($id_prefix . '-ajax-wrapper');
$item = $items[$delta];
$full_country_list = $this->countryRepository->getList();
$country_list = $full_country_list;
$available_countries = $item->getAvailableCountries();
if (!empty($available_countries)) {
$country_list = array_intersect_key($country_list, $available_countries);
}
// If the form has been rebuilt via AJAX, use the values from user input.
// $form_state->getValues() can't be used here because it's empty due to
// #limit_validaiton_errors.
$parents = array_merge($element['#field_parents'], [$field_name, $delta]);
$values = NestedArray::getValue($form_state->getUserInput(), $parents, $has_input);
if (!$has_input) {
$values = $item->isEmpty() ? $this->getInitialValues($country_list) : $item->toArray();
}
$country_code = $values['country_code'];
if (!empty($country_code) && !isset($country_list[$country_code])) {
// This item's country is no longer available. Add it back to the top
// of the list to ensure all data is displayed properly. The validator
// can then prevent the save and tell the user to change the country.
$missingElement = [
$country_code => $full_country_list[$country_code],
];
$country_list = $missingElement + $country_list;
}
$value = $item->isEmpty() ? $this->getInitialValues() : $item->toArray();
// Calling initializeLangcode() every time, and not just when the field
// is empty, ensures that the langcode can be changed on subsequent
// edits (because the entity or interface language changed, for example).
$langcode = $item->initializeLangcode();
$value['langcode'] = $item->initializeLangcode();
$element += [
'#type' => 'details',
'#collapsible' => TRUE,
'#open' => TRUE,
'#prefix' => '<div id="' . $wrapper_id . '">',
'#suffix' => '</div>',
'#pre_render' => [
['Drupal\Core\Render\Element\Details', 'preRenderDetails'],
['Drupal\Core\Render\Element\Details', 'preRenderGroup'],
[get_class($this), 'groupElements'],
],
'#after_build' => [
[get_class($this), 'clearValues'],
],
'#attached' => [
'library' => ['address/form'],
],
// Pass the id along to other methods.
'#wrapper_id' => $wrapper_id,
];
$element['langcode'] = [
'#type' => 'hidden',
'#value' => $langcode,
$element['address'] = [
'#type' => 'address',
'#default_value' => $value,
'#required' => $this->fieldDefinition->isRequired(),
'#available_countries' => $item->getAvailableCountries(),
'#used_fields' => $this->getFieldSetting('fields'),
];
// Hide the country dropdown when there is only one possible value.
if (count($country_list) == 1 && $this->fieldDefinition->isRequired()) {
$country_code = key($available_countries);