Commit 34060936 authored by bojanz's avatar bojanz

Issue #2872570 by bojanz: Provide a zone field type

parent e7ed929d
......@@ -85,7 +85,7 @@ field.field_settings.address:
field.widget.settings.address_default:
type: mapping
label: 'Default address formatter settings'
label: 'Default address widget settings'
mapping:
default_country:
type: string
......@@ -109,6 +109,37 @@ field.field_settings.address_country:
sequence:
- type: string
field.value.address_zone:
type: mapping
label: 'Default value'
mapping:
label:
type: label
label: 'Label'
territories:
type: sequence
label: 'Territories'
sequence:
- type: zone_territory
field.field_settings.address_zone:
type: mapping
label: 'Zone field settings'
mapping:
available_countries:
type: sequence
label: 'Available countries'
sequence:
- type: string
field.widget.settings.address_zone_default:
type: mapping
label: 'Default zone widget settings'
mapping:
show_label_field:
type: boolean
label: 'Show the zone label field'
views.filter.country_code:
type: views.filter.in_operator
label: 'Country'
<?php
namespace Drupal\address\Element;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element\FormElement;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\NestedArray;
/**
* Provides a zone form element.
*
* Use it to populate a \CommerceGuys\Addressing\Zone\Zone object.
*
* Note that the default value does not need to contain a 'label'
* property if #show_label_field is FALSE.
*
* Usage example:
* @code
* $form['zone'] = [
* '#type' => 'address_zone',
* '#default_value' => [
* 'label' => t('California and Nevada'),
* 'territories' => [
* ['country_code' => 'US', 'administrative_area' => 'CA'],
* ['country_code' => 'US', 'administrative_area' => 'NV'],
* ],
* ],
* '#show_label_field' => TRUE,
* '#available_countries' => ['US', 'FR'],
* ];
* @endcode
*
* @FormElement("address_zone")
*/
class Zone extends FormElement {
/**
* {@inheritdoc}
*/
public function getInfo() {
$class = get_called_class();
return [
'#show_label_field' => FALSE,
// List of country codes. If empty, all countries will be available.
'#available_countries' => [],
'#input' => TRUE,
'#multiple' => FALSE,
'#default_value' => NULL,
'#process' => [
[$class, 'processZone'],
[$class, 'processGroup'],
],
'#element_validate' => [
[$class, 'validateZone'],
],
'#theme_wrappers' => ['container'],
];
}
/**
* {@inheritdoc}
*/
public static function valueCallback(&$element, $input, FormStateInterface $form_state) {
if (is_array($input)) {
$value = $input;
}
else {
if (!is_array($element['#default_value'])) {
$element['#default_value'] = [];
}
$value = $element['#default_value'];
}
// Initialize default keys.
foreach (['label', 'territories'] as $property) {
if (!isset($value[$property])) {
$value[$property] = NULL;
}
}
return $value;
}
/**
* Processes the zone 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 the #default_value is malformed.
*/
public static function processZone(array &$element, FormStateInterface $form_state, array &$complete_form) {
if (!empty($element['#default_value']['territories']) && !is_array($element['#default_value']['territories'])) {
throw new \InvalidArgumentException('The #default_value "territories" property must be an array.');
}
$id_prefix = implode('-', $element['#parents']);
$wrapper_id = Html::getUniqueId($id_prefix . '-ajax-wrapper');
$button_id_prefix = implode('_', $element['#parents']);
$value = $element['#value'];
$element_state = self::getElementState($element['#parents'], $form_state);
if (!isset($element_state['territories'])) {
// Default to a single empty row if no other value was provided.
$element_state['territories'] = $value['territories'];
$element_state['territories'] = $element_state['territories'] ?: [NULL];
self::setElementState($element['#parents'], $form_state, $element_state);
}
$element['#required'] = TRUE;
$element = [
'#tree' => TRUE,
'#prefix' => '<div id="' . $wrapper_id . '">',
'#suffix' => '</div>',
] + $element;
$element['label'] = [
'#type' => 'textfield',
'#title' => t('Zone label'),
'#default_value' => $value['label'],
'#access' => $element['#show_label_field'],
];
$element['territories'] = [
'#type' => 'table',
'#header' => [
t('Territory'),
t('Operations'),
],
'#input' => FALSE,
];
foreach ($element_state['territories'] as $index => $territory) {
$territory_form = &$element['territories'][$index];
$territory_form['territory'] = [
'#type' => 'address_zone_territory',
'#default_value' => $territory,
'#available_countries' => $element['#available_countries'],
'#required' => $element['#required'],
// Remove the 'territory' level from form state values.
'#parents' => array_merge($element['#parents'], ['territories', $index]),
];
$territory_form['remove'] = [
'#type' => 'submit',
'#name' => $button_id_prefix . '_remove_territory' . $index,
'#value' => t('Remove'),
'#limit_validation_errors' => [],
'#submit' => [[get_called_class(), 'removeTerritorySubmit']],
'#territory_index' => $index,
'#ajax' => [
'callback' => [get_called_class(), 'ajaxRefresh'],
'wrapper' => $wrapper_id,
],
];
}
$element['territories'][] = [
'add_territory' => [
'#type' => 'submit',
'#name' => $button_id_prefix . '_add_territory',
'#value' => t('Add territory'),
'#submit' => [[get_called_class(), 'addTerritorySubmit']],
'#limit_validation_errors' => [],
'#ajax' => [
'callback' => [get_called_class(), 'ajaxRefresh'],
'wrapper' => $wrapper_id,
],
],
];
return $element;
}
/**
* Validates the zone.
*
* @param array $element
* The form element.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*/
public static function validateZone(array $element, FormStateInterface $form_state) {
$value = $form_state->getValue($element['#parents']);
// Remove empty territories, unneeded keys.
foreach ($value['territories'] as $index => $territory) {
if (empty($territory['country_code'])) {
unset($value['territories'][$index]);
}
unset($territory['remove']);
unset($territory['add_territory']);
}
$value['territories'] = array_filter($value['territories']);
$form_state->setValue($element['#parents'], $value);
// Required zones must always have a territory.
// @todo Invent a nicer UX for optional zones.
if ($element['#required'] && empty($value['territories'])) {
$form_state->setError($element['territories'], t('Please add at least one territory.'));
}
}
/**
* Ajax callback.
*/
public static function ajaxRefresh(array $form, FormStateInterface $form_state) {
$triggering_element = $form_state->getTriggeringElement();
return NestedArray::getValue($form, array_slice($triggering_element['#array_parents'], 0, -3));
}
/**
* Submit callback for adding a new territory.
*/
public static function addTerritorySubmit(array $form, FormStateInterface $form_state) {
$triggering_element = $form_state->getTriggeringElement();
$element_parents = array_slice($triggering_element['#parents'], 0, -3);
$element_state = self::getElementState($element_parents, $form_state);
$element_state['territories'][] = NULL;
self::setElementState($element_parents, $form_state, $element_state);
$form_state->setRebuild();
}
/**
* Submit callback for removing a territory.
*/
public static function removeTerritorySubmit(array $form, FormStateInterface $form_state) {
$triggering_element = $form_state->getTriggeringElement();
$element_parents = array_slice($triggering_element['#parents'], 0, -3);
$element_state = self::getElementState($element_parents, $form_state);
$territory_index = $triggering_element['#territory_index'];
unset($element_state['territories'][$territory_index]);
self::setElementState($element_parents, $form_state, $element_state);
$form_state->setRebuild();
}
/**
* Gets the element state.
*
* @param array $parents
* The element parents.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*
* @return array
* The element state.
*/
public static function getElementState(array $parents, FormStateInterface $form_state) {
$parents = array_merge(['element_state', '#parents'], $parents);
return NestedArray::getValue($form_state->getStorage(), $parents);
}
/**
* Sets the element state.
*
* @param array $parents
* The element parents.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param array $element_state
* The element state.
*/
public static function setElementState(array $parents, FormStateInterface $form_state, array $element_state) {
$parents = array_merge(['element_state', '#parents'], $parents);
NestedArray::setValue($form_state->getStorage(), $parents, $element_state);
}
}
......@@ -19,7 +19,7 @@ use Drupal\Component\Utility\NestedArray;
* Usage example:
* @code
* $form['territory'] = [
* '#type' => 'zone_territory',
* '#type' => 'address_zone_territory',
* '#default_value' => [
* 'country_code' => 'US',
* 'administrative_area' => 'CA',
......@@ -28,7 +28,7 @@ use Drupal\Component\Utility\NestedArray;
* ];
* @endcode
*
* @FormElement("zone_territory")
* @FormElement("address_zone_territory")
*/
class ZoneTerritory extends FormElement {
......
<?php
namespace Drupal\address\Plugin\Field\FieldType;
use CommerceGuys\Addressing\Zone\Zone;
use Drupal\Core\Field\FieldItemBase;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\TypedData\DataDefinition;
/**
* Plugin implementation of the 'zone' field type.
*
* @FieldType(
* id = "address_zone",
* label = @Translation("Zone"),
* description = @Translation("An entity field containing a zone"),
* category = @Translation("Address"),
* default_widget = "address_zone_default",
* cardinality = 1,
* )
*/
class ZoneItem extends FieldItemBase {
use AvailableCountriesTrait;
/**
* {@inheritdoc}
*/
public static function schema(FieldStorageDefinitionInterface $field_definition) {
return [
'columns' => [
'value' => [
'description' => 'The serialized zone.',
'type' => 'blob',
'not null' => TRUE,
'serialize' => TRUE,
],
],
];
}
/**
* {@inheritdoc}
*/
public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) {
$properties = [];
$properties['value'] = DataDefinition::create('any')
->setLabel(t('Value'))
->setRequired(TRUE);
return $properties;
}
/**
* {@inheritdoc}
*/
public static function defaultFieldSettings() {
return self::defaultCountrySettings();
}
/**
* {@inheritdoc}
*/
public function fieldSettingsForm(array $form, FormStateInterface $form_state) {
return $this->countrySettingsForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function isEmpty() {
return $this->value === NULL || !$this->value instanceof Zone;
}
/**
* {@inheritdoc}
*/
public function setValue($values, $notify = TRUE) {
if (is_array($values)) {
// The property definition causes the zone to be in 'value' key.
$values = reset($values);
}
if (!$values instanceof Zone) {
$values = NULL;
}
parent::setValue($values, $notify);
}
}
<?php
namespace Drupal\address\Plugin\Field\FieldWidget;
use CommerceGuys\Addressing\Zone\Zone;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\WidgetBase;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\Validator\ConstraintViolationInterface;
/**
* Plugin implementation of the 'address_zone_default' widget.
*
* @FieldWidget(
* id = "address_zone_default",
* label = @Translation("Zone"),
* field_types = {
* "address_zone"
* },
* )
*/
class ZoneDefaultWidget extends WidgetBase {
/**
* {@inheritdoc}
*/
public static function defaultSettings() {
return [
'show_label_field' => FALSE,
] + parent::defaultSettings();
}
/**
* {@inheritdoc}
*/
public function settingsForm(array $form, FormStateInterface $form_state) {
$element = [];
$element['show_label_field'] = [
'#type' => 'checkbox',
'#title' => $this->t('Show the zone label field'),
'#default_value' => $this->getSetting('show_label_field'),
];
return $element;
}
/**
* {@inheritdoc}
*/
public function settingsSummary() {
$summary = [];
$summary['show_label_field'] = $this->t('Zone label field: @status', [
'@status' => $this->getSetting('show_label_field') ? $this->t('Shown') : $this->t('Hidden'),
]);
return $summary;
}
/**
* {@inheritdoc}
*/
public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
$item = $items[$delta];
$value = [];
if (!$item->isEmpty()) {
/** @var \CommerceGuys\Addressing\Zone\Zone $zone */
$zone = $item->value;
$value = [
'label' => $zone->getLabel(),
'territories' => [],
];
foreach ($zone->getTerritories() as $territory) {
$value['territories'][] = [
'country_code' => $territory->getCountryCode(),
'administrative_area' => $territory->getAdministrativeArea(),
'locality' => $territory->getLocality(),
'dependent_locality' => $territory->getDependentLocality(),
'included_postal_codes' => $territory->getIncludedPostalCodes(),
'excluded_postal_codes' => $territory->getExcludedPostalCodes(),
];
}
}
$element += [
'#type' => 'details',
'#collapsible' => TRUE,
'#open' => TRUE,
];
$element['zone'] = [
'#type' => 'address_zone',
'#default_value' => $value,
'#required' => $this->fieldDefinition->isRequired(),
'#show_label_field' => $this->getSetting('show_label_field'),
'#available_countries' => $item->getAvailableCountries(),
];
return $element;
}
/**
* {@inheritdoc}
*/
public function errorElement(array $element, ConstraintViolationInterface $violation, array $form, FormStateInterface $form_state) {
$error_element = NestedArray::getValue($element['zone'], $violation->arrayPropertyPath);
return is_array($error_element) ? $error_element : FALSE;
}
/**
* {@inheritdoc}
*/
public function massageFormValues(array $values, array $form, FormStateInterface $form_state) {
$new_values = [];
foreach ($values as $delta => $value) {
if (empty($value['zone']['territories'])) {
// Zones with no territories are considered empty.
continue;
}
$new_values[$delta] = new Zone([
'id' => $this->fieldDefinition->getName(),
'label' => $value['zone']['label'] ?: $this->fieldDefinition->getLabel(),
'territories' => $value['zone']['territories'],
]);
}
return $new_values;
}
}
<?php
namespace Drupal\Tests\address\Kernel;
use CommerceGuys\Addressing\Zone\Zone;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\KernelTests\Core\Entity\EntityKernelTestBase;
/**
* Tests the address_zone field.
*
* @group commerce
*/
class ZoneItemTest extends EntityKernelTestBase {
/**
* @var array
*/
public static $modules = [
'address',
];
/**
* The test entity.
*
* @var \Drupal\entity_test\Entity\EntityTest
*/
protected $testEntity;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$field_storage = FieldStorageConfig::create([
'field_name' => 'field_zone',
'entity_type' => 'entity_test',
'type' => 'address_zone',
'cardinality' => 1,
]);
$field_storage->save();
$field = FieldConfig::create([
'field_name' => 'field_zone',
'entity_type' => 'entity_test',
'bundle' => 'entity_test',
]);
$field->save();
$entity = EntityTest::create([
'name' => 'Test',
]);
$entity->save();
$this->testEntity = $entity;
}
/**
* Tests storing and retrieving a zone from the field.
*/
public function testZone() {
$zone = new Zone([
'id' => 'test',
'label' => 'Test',
'territories' => [
['country_code' => 'HU'],