Commit 52771351 authored by alexpott's avatar alexpott

Issue #1758622 by klausi, fago, das-peter, Pancho, Berdir, yched: Provide the...

Issue #1758622 by klausi, fago, das-peter, Pancho, Berdir, yched: Provide the options list of an entity field.
parent 94cd6717
<?php
/**
* @file
* Contains \Drupal\Core\TypedData\AllowedValuesInterface.
*/
namespace Drupal\Core\TypedData;
use Drupal\Core\Session\AccountInterface;
/**
* Interface for retrieving all possible and settable values.
*
* While possible values specify which values existing data might have, settable
* values define the values that are allowed to be set by a user.
*
* For example, in an workflow scenario, the settable values for a state field
* might depend on the currently set state, while possible values are all
* states. Thus settable values would be used in an editing context, while
* possible values would be used for presenting filtering options in a search.
*
* For convenience, lists of both settable and possible values are also provided
* as structured options arrays that can be used in an Options widget such as a
* select box or checkboxes.
*
* @see \Drupal\options\Plugin\field\widget\OptionsWidgetBase
*/
interface AllowedValuesInterface {
/**
* Returns an array of possible values.
*
* If the optional $account parameter is passed, then the array is filtered to
* values viewable by the account.
*
* @param \Drupal\Core\Session\AccountInterface $account
* (optional) The user account for which to filter the possible values. If
* omitted, all possible values are returned.
*
* @return array
* An array of possible values.
*/
public function getPossibleValues(AccountInterface $account = NULL);
/**
* Returns an array of possible values with labels for display.
*
* If the optional $account parameter is passed, then the array is filtered to
* values viewable by the account.
*
* @param \Drupal\Core\Session\AccountInterface $account
* (optional) The user account for which to filter the possible options.
* If omitted, all possible options are returned.
*
* @return array
* An array of possible options for the object that may be used in an
* Options widget, for example when existing data should be filtered. It may
* either be a flat array of option labels keyed by values, or a
* two-dimensional array of option groups (array of flat option arrays,
* keyed by option group label). Note that labels should NOT be sanitized.
*/
public function getPossibleOptions(AccountInterface $account = NULL);
/**
* Returns an array of settable values.
*
* If the optional $account parameter is passed, then the array is filtered to
* values settable by the account.
*
* @param \Drupal\Core\Session\AccountInterface $account
* (optional) The user account for which to filter the settable values. If
* omitted, all settable values are returned.
*
* @return array
* An array of settable values.
*/
public function getSettableValues(AccountInterface $account = NULL);
/**
* Returns an array of settable values with labels for display.
*
* If the optional $account parameter is passed, then the array is filtered to
* values settable by the account.
*
* @param \Drupal\Core\Session\AccountInterface $account
* (optional) The user account for which to filter the settable options. If
* omitted, all settable options are returned.
*
* @return array
* An array of settable options for the object that may be used in an
* Options widget, usually when new data should be entered. It may either be
* a flat array of option labels keyed by values, or a two-dimensional array
* of option groups (array of flat option arrays, keyed by option group
* label). Note that labels should NOT be sanitized.
*/
public function getSettableOptions(AccountInterface $account = NULL);
}
......@@ -377,6 +377,15 @@ public function getConstraints($definition) {
if (!empty($definition['required']) && empty($definition['constraints']['NotNull'])) {
$constraints[] = $validation_manager->create('NotNull', array());
}
// If the definition does not provide a class use the class from the type
// definition for performing interface checks.
$class = isset($definition['class']) ? $definition['class'] : $type_definition['class'];
// Check if the class provides allowed values.
if (array_key_exists('Drupal\Core\TypedData\AllowedValuesInterface', class_implements($class))) {
$constraints[] = $validation_manager->create('AllowedValues', array());
}
return $constraints;
}
}
<?php
/**
* @file
* Contains \Drupal\Core\Validation\Constraint\AllowedValuesConstraint.
*/
namespace Drupal\Core\Validation\Plugin\Validation\Constraint;
use Symfony\Component\Validator\Constraints\Choice;
use Drupal\Component\Annotation\Plugin;
use Drupal\Core\Annotation\Translation;
/**
* Checks for the value being allowed.
*
* @Plugin(
* id = "AllowedValues",
* label = @Translation("Allowed values", context = "Validation")
* )
*
* @see \Drupal\Core\TypedData\AllowedValuesInterface
*/
class AllowedValuesConstraint extends Choice {
public $minMessage = 'You must select at least %limit choice.|You must select at least %limit choices.';
public $maxMessage = 'You must select at most %limit choice.|You must select at most %limit choices.';
}
<?php
/**
* @file
* Contains \Drupal\Core\Validation\Plugin\Validation\Constraint\AllowedValuesConstraintValidator.
*/
namespace Drupal\Core\Validation\Plugin\Validation\Constraint;
use Drupal\Core\TypedData\AllowedValuesInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints\ChoiceValidator;
/**
* Validates the AllowedValues constraint.
*/
class AllowedValuesConstraintValidator extends ChoiceValidator {
/**
* {@inheritdoc}
*/
public function validate($value, Constraint $constraint) {
if ($this->context->getMetadata()->getTypedData() instanceof AllowedValuesInterface) {
$account = \Drupal::currentUser();
$allowed_values = $this->context->getMetadata()->getTypedData()->getSettableValues($account);
$constraint->choices = $allowed_values;
}
return parent::validate($value, $constraint);
}
}
......@@ -19,7 +19,7 @@ class EditTestBase extends DrupalUnitTestBase {
*
* @var array
*/
public static $modules = array('system', 'entity', 'entity_test', 'field', 'field_sql_storage', 'field_test', 'number', 'text', 'edit');
public static $modules = array('system', 'entity', 'entity_test', 'field', 'field_sql_storage', 'field_test', 'number', 'filter', 'user', 'text', 'edit');
/**
* Sets the default field storage backend for fields created during tests.
*/
......@@ -28,7 +28,7 @@ function setUp() {
$this->installSchema('system', 'variable');
$this->installSchema('entity_test', array('entity_test', 'entity_test_rev'));
$this->installConfig(array('field'));
$this->installConfig(array('field', 'filter'));
}
/**
......
......@@ -147,6 +147,24 @@ public function instanceSettingsForm(array $form, array &$form_state) {
return array();
}
/**
* Returns options provided via the legacy callback hook_options_list().
*
* @todo: Convert all legacy callback implementations to methods.
*
* @see \Drupal\Core\TypedData\AllowedValuesInterface
*/
public function getSettableOptions() {
$definition = $this->getPluginDefinition();
$callback = "{$definition['provider']}_options_list";
if (function_exists($callback)) {
// We are at the field item level, so we need to go two levels up to get
// to the entity object.
$entity = $this->getParent()->getParent();
return $callback($this->getInstance(), $entity);
}
}
/**
* Returns the legacy callback for a given field type "hook".
*
......
......@@ -370,7 +370,9 @@ public function flagErrors(EntityInterface $entity, $langcode, FieldInterface $i
foreach ($delta_violations as $violation) {
// @todo: Pass $violation->arrayPropertyPath as property path.
$error_element = $this->errorElement($delta_element, $violation, $form, $form_state);
form_error($error_element, $violation->getMessage());
if ($error_element !== FALSE) {
form_error($error_element, $violation->getMessage());
}
}
}
// Reinitialize the errors list for the next submit.
......
......@@ -126,18 +126,19 @@ public function formElement(FieldInterface $items, $delta, array $element, $lang
* @param array $element
* An array containing the form element for the widget, as generated by
* formElement().
* @param \Symfony\Component\Validator\ConstraintViolationInterface $violations
* The list of constraint violations reported during the validation phase.
* @param \Symfony\Component\Validator\ConstraintViolationInterface $violation
* A constraint violation reported during the validation phase.
* @param array $form
* The form structure where field elements are attached to. This might be a
* full form structure, or a sub-element of a larger form.
* @param array $form_state
* An associative array containing the current state of the form.
*
* @return array
* The element on which the error should be flagged.
* @return array|bool
* The element on which the error should be flagged, or FALSE to completely
* ignore the violation (use with care!).
*/
public function errorElement(array $element, ConstraintViolationInterface $violations, array $form, array &$form_state);
public function errorElement(array $element, ConstraintViolationInterface $violation, array $form, array &$form_state);
/**
* Massages the form values into the format expected for field values.
......
......@@ -107,6 +107,22 @@ public function prepareCache() {
}
}
/**
* Returns options provided via the legacy callback hook_options_list().
*
* @todo: Convert all legacy callback implementations to methods.
*
* @see \Drupal\Core\TypedData\AllowedValuesInterface
*/
public function getSettableOptions() {
$definition = $this->getPluginDefinition();
$callback = "{$definition['provider']}_options_list";
if (function_exists($callback)) {
$entity = $this->getParent()->getParent();
return $callback($this->getInstance(), $entity);
}
}
/**
* Returns the legacy callback for a given field type "hook".
*
......
<?php
/**
* @file
* Contains \Drupal\filter\Plugin\DataType\FilterFormat.
*/
namespace Drupal\filter\Plugin\DataType;
use Drupal\Core\TypedData\Annotation\DataType;
use Drupal\Core\Annotation\Translation;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\TypedData\AllowedValuesInterface;
use Drupal\Core\TypedData\Plugin\DataType\String;
/**
* The filter format data type.
*
* @DataType(
* id = "filter_format",
* label = @Translation("Filter format")
* )
*/
class FilterFormat extends String implements AllowedValuesInterface {
/**
* {@inheritdoc}
*/
public function getPossibleValues(AccountInterface $account = NULL) {
return array_keys($this->getPossibleOptions());
}
/**
* {@inheritdoc}
*/
public function getPossibleOptions(AccountInterface $account = NULL) {
return array_map(function ($format) { return $format->label(); }, filter_formats());
}
/**
* {@inheritdoc}
*/
public function getSettableValues(AccountInterface $account = NULL) {
return array_keys($this->getSettableOptions($account));
}
/**
* {@inheritdoc}
*/
public function getSettableOptions(AccountInterface $account = NULL) {
// @todo: Avoid calling functions but move to injected dependencies.
return array_map(function ($format) { return $format->label(); }, filter_formats($account));
}
}
......@@ -7,14 +7,17 @@
namespace Drupal\filter\Tests;
use Drupal\simpletest\DrupalUnitTestBase;
use Drupal\Core\TypedData\AllowedValuesInterface;
use Drupal\filter\Plugin\DataType\FilterFormat;
use Drupal\system\Tests\Entity\EntityUnitTestBase;
use Symfony\Component\Validator\ConstraintViolationListInterface;
/**
* Tests the behavior of Filter's API.
*/
class FilterAPITest extends DrupalUnitTestBase {
class FilterAPITest extends EntityUnitTestBase {
public static $modules = array('system', 'filter', 'filter_test');
public static $modules = array('system', 'filter', 'filter_test', 'user');
public static function getInfo() {
return array(
......@@ -27,7 +30,8 @@ public static function getInfo() {
function setUp() {
parent::setUp();
$this->installConfig(array('system'));
$this->installConfig(array('system', 'filter'));
$this->installSchema('user', array('users_roles'));
// Create Filtered HTML format.
$filtered_html_format = entity_create('filter_format', array(
......@@ -184,4 +188,87 @@ function testFilterFormatAPI() {
);
}
/**
* Tests the function of the typed data type.
*/
function testTypedDataAPI() {
$definition = array('type' => 'filter_format');
$data = \Drupal::typedData()->create($definition);
$this->assertTrue($data instanceof AllowedValuesInterface, 'Typed data object implements \Drupal\Core\TypedData\AllowedValuesInterface');
$filtered_html_user = $this->createUser(array('uid' => 2), array(
filter_permission_name(filter_format_load('filtered_html')),
));
// Test with anonymous user.
$user = drupal_anonymous_user();
$this->container->set('current_user', $user);
$available_values = $data->getPossibleValues();
$this->assertEqual($available_values, array('filtered_html', 'full_html', 'plain_text'));
$available_options = $data->getPossibleOptions();
$expected_available_options = array(
'filtered_html' => 'Filtered HTML',
'full_html' => 'Full HTML',
'plain_text' => 'Plain text',
);
$this->assertEqual($available_options, $expected_available_options);
$allowed_values = $data->getSettableValues($user);
$this->assertEqual($allowed_values, array('plain_text'));
$allowed_options = $data->getSettableOptions($user);
$this->assertEqual($allowed_options, array('plain_text' => 'Plain text'));
$data->setValue('foo');
$violations = $data->validate();
$this->assertFilterFormatViolation($violations, 'foo');
// Make sure the information provided by a violation is correct.
$violation = $violations[0];
$this->assertEqual($violation->getRoot(), $data, 'Violation root is filter format.');
$this->assertEqual($violation->getPropertyPath(), '', 'Violation property path is correct.');
$this->assertEqual($violation->getInvalidValue(), 'foo', 'Violation contains invalid value.');
$data->setValue('plain_text');
$violations = $data->validate();
$this->assertEqual(count($violations), 0, "No validation violation for format 'plain_text' found");
// Anonymous doesn't have access to the 'filtered_html' format.
$data->setValue('filtered_html');
$violations = $data->validate();
$this->assertFilterFormatViolation($violations, 'filtered_html');
// Set user with access to 'filtered_html' format.
$this->container->set('current_user', $filtered_html_user);
$violations = $data->validate();
$this->assertEqual(count($violations), 0, "No validation violation for accessible format 'filtered_html' found.");
$allowed_values = $data->getSettableValues($filtered_html_user);
$this->assertEqual($allowed_values, array('filtered_html', 'plain_text'));
$allowed_options = $data->getSettableOptions($filtered_html_user);
$expected_allowed_options = array(
'filtered_html' => 'Filtered HTML',
'plain_text' => 'Plain text',
);
$this->assertEqual($allowed_options, $expected_allowed_options);
}
/**
* Checks if an expected violation exists in the given violations.
*
* @param \Symfony\Component\Validator\ConstraintViolationListInterface $violations
* The violations to assert.
* @param mixed $invalid_value
* The expected invalid value.
*/
public function assertFilterFormatViolation(ConstraintViolationListInterface $violations, $invalid_value) {
$filter_format_violation_found = FALSE;
foreach ($violations as $violation) {
if ($violation->getRoot() instanceof FilterFormat && $violation->getInvalidValue() === $invalid_value) {
$filter_format_violation_found = TRUE;
break;
}
}
$this->assertTrue($filter_format_violation_found, format_string('Validation violation for invalid value "%invalid_value" found', array('%invalid_value' => $invalid_value)));
}
}
......@@ -30,7 +30,7 @@
*
* @var array
*/
public static $modules = array('entity', 'entity_test', 'entity_reference', 'field', 'field_sql_storage', 'hal', 'language', 'rest', 'serialization', 'system', 'text', 'user');
public static $modules = array('entity', 'entity_test', 'entity_reference', 'field', 'field_sql_storage', 'hal', 'language', 'rest', 'serialization', 'system', 'text', 'user', 'filter');
/**
* The mock serializer.
......
......@@ -19,7 +19,7 @@ class NodeValidationTest extends DrupalUnitTestBase {
*
* @var array
*/
public static $modules = array('node', 'entity', 'field', 'text', 'field_sql_storage');
public static $modules = array('node', 'entity', 'field', 'text', 'field_sql_storage', 'filter');
public static function getInfo() {
return array(
......
......@@ -34,7 +34,7 @@ class ButtonsWidget extends OptionsWidgetBase {
public function formElement(FieldInterface $items, $delta, array $element, $langcode, array &$form, array &$form_state) {
$element = parent::formElement($items, $delta, $element, $langcode, $form, $form_state);
$options = $this->getOptions();
$options = $this->getOptions($items[$delta]);
$selected = $this->getSelectedOptions($items);
// If required and there is one single option, preselect it.
......
......@@ -59,7 +59,7 @@ public function settingsSummary() {
public function formElement(FieldInterface $items, $delta, array $element, $langcode, array &$form, array &$form_state) {
$element = parent::formElement($items, $delta, $element, $langcode, $form, $form_state);
$options = $this->getOptions();
$options = $this->getOptions($items[$delta]);
$selected = $this->getSelectedOptions($items);
$element += array(
......
......@@ -9,10 +9,18 @@
use Drupal\Core\Entity\Field\FieldDefinitionInterface;
use Drupal\Core\Entity\Field\FieldInterface;
use Drupal\Core\Entity\Field\FieldItemInterface;
use Drupal\field\Plugin\Type\Widget\WidgetBase;
/**
* Base class for the 'options_*' widgets.
*
* Field types willing to enable one or several of the widgets defined in
* options.module (select, radios/checkboxes, on/off checkbox) need to
* implement the AllowedValuesInterface to specify the list of options to
* display in the widgets.
*
* @see \Drupal\Core\TypedData\AllowedValuesInterface
*/
abstract class OptionsWidgetBase extends WidgetBase {
......@@ -49,7 +57,6 @@ public function __construct($plugin_id, array $plugin_definition, FieldDefinitio
public function formElement(FieldInterface $items, $delta, array $element, $langcode, array &$form, array &$form_state) {
// Prepare some properties for the child methods to build the actual form
// element.
$this->entity = $element['#entity'];
$this->required = $element['#required'];
$cardinality = $this->fieldDefinition->getFieldCardinality();
$this->multiple = ($cardinality == FIELD_CARDINALITY_UNLIMITED) || ($cardinality > 1);
......@@ -107,17 +114,16 @@ public static function validateElement(array $element, array &$form_state) {
/**
* Returns the array of options for the widget.
*
* @param \Drupal\Core\Entity\Field\FieldItemInterface $item
* The field item.
*
* @return array
* The array of options for the widget.
*/
protected function getOptions() {
protected function getOptions(FieldItemInterface $item) {
if (!isset($this->options)) {
$module_handler = \Drupal::moduleHandler();
// Get the list of options from the field type module, and sanitize them.
$field_type_info = \Drupal::service('plugin.manager.entity.field.field_type')->getDefinition($this->fieldDefinition->getFieldType());
$module = $field_type_info['provider'];
$options = (array) $module_handler->invoke($module, 'options_list', array($this->fieldDefinition, $this->entity));
// Limit the settable options for the current user account.
$options = $item->getSettableOptions(\Drupal::currentUser());
// Add an empty option if the widget needs one.
if ($empty_option = $this->getEmptyOption()) {
......@@ -134,9 +140,10 @@ protected function getOptions() {
$options = array('_none' => $label) + $options;
}
$module_handler = \Drupal::moduleHandler();
$context = array(
'fieldDefinition' => $this->fieldDefinition,
'entity' => $this->entity,
'entity' => $item->getParent()->getParent(),
);
$module_handler->alter('options_list', $options, $context);
......@@ -158,13 +165,15 @@ protected function getOptions() {
*
* @param FieldInterface $items
* The field values.
* @param int $delta
* (optional) The delta of the item to get options for. Defaults to 0.
*
* @return array
* The array of corresponding selected options.
*/
protected function getSelectedOptions(FieldInterface $items) {
protected function getSelectedOptions(FieldInterface $items, $delta = 0) {
// We need to check against a flat list of options.
$flat_options = $this->flattenOptions($this->getOptions());
$flat_options = $this->flattenOptions($this->getOptions($items[$delta]));
$selected_options = array();
foreach ($items as $item) {
......
......@@ -35,8 +35,8 @@ public function formElement(FieldInterface $items, $delta, array $element, $lang
$element += array(
'#type' => 'select',
'#options' => $this->getOptions(),
'#default_value' => $this->getSelectedOptions($items),
'#options' => $this->getOptions($items[$delta]),
'#default_value' => $this->getSelectedOptions($items, $delta),
// Do not display a 'multiple' select box if there is only one option.
'#multiple' => $this->multiple && count($this->options) > 1,
);
......
......@@ -70,8 +70,11 @@ function testUpdateAllowedValues() {
$this->assertTrue(empty($form[$this->fieldName][$langcode][3]), 'Option 3 does not exist');
// Completely new options appear.
$this->field['settings']['allowed_values'] = array(10 => 'Update', 20 => 'Twenty');
$this->field->settings['allowed_values'] = array(10 => 'Update', 20 => 'Twenty');
$this->field->save();
// The entity holds an outdated field object with the old allowed values
// setting, so we need to reintialize the entity object.
$entity = entity_create('entity_test', array());
$form = \Drupal::entityManager()->getForm($entity);
$this->assertTrue(empty($form[$this->fieldName][$langcode][1]), 'Option 1 does not exist');
$this->assertTrue(empty($form[$this->fieldName][$langcode][2]), 'Option 2 does not exist');
......
......@@ -5,73 +5,14 @@
* Hooks provided by the Options module.
*/
/**
* Returns the list of options to be displayed for a field.
*
* Field types willing to enable one or several of the widgets defined in
* options.module (select, radios/checkboxes, on/off checkbox) need to
* implement this hook to specify the list of options to display in the
* widgets.
*
* @param \Drupal\Core\Entity\Field\FieldDefinitionInterface $field_definition
* The field definition.
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity object the field is attached to.
*
* @return
* The array of options for the field. Array keys are the values to be
* stored, and should be of the data type (string, number...) expected by
* the first 'column' for the field type. Array values are the labels to
* display within the widgets. The labels should NOT be sanitized,
* options.module takes care of sanitation according to the needs of each
* widget. The HTML tags defined in _field_filter_xss_allowed_tags() are
* allowed, other tags will be filtered.
*/
function hook_options_list(\Drupal\Core\Entity\Field\FieldDefinitionInterface $field_definition, \Drupal\Core\Entity\EntityInterface $entity) {
// Sample structure.
$options = array(
0 => t('Zero'),
1 => t('One'),
2 => t('Two'),
3 => t('Three'),
);
// Sample structure with groups. Only one level of nesting is allowed. This
// is only supported by the 'options_select' widget. Other widgets will
// flatten the array.
$options = array(
t('First group') => array(
0 => t('Zero'),
),
t('Second group') => array(
1 => t('One'),
2 => t('Two'),
),
3 => t('Three'),
);
// In actual implementations, the array of options will most probably depend
// on properties of the field. Example from taxonomy.module:
$options = array();
foreach ($field_definition->getFieldSetting('allowed_values') as $tree) {
$terms = taxonomy_get_tree($tree['vid'], $tree['parent'], NULL, TRUE);
if ($terms) {
foreach ($terms as $term) {
$options[$term->id()] = str_repeat('-', $term->depth) . $term->label();
}
}
}
return $options;
}
/**
* Alters the list of options to be displayed for a field.
*
* This hook can notably be used to change the label of the empty option.
*
* @param array $options
* The array of options for the field, as returned by hook_options_list(). An
* The array of options for the field, as returned by
* \Drupal\Core\TypedData\AllowedValuesInterface::getSettableOptions(). An
* empty option (_none) might have been added, depending on the field
* properties.
*
......
......@@ -16,7 +16,7 @@
*
* @var array
*/
public static $modules = array('serialization', 'system', 'entity', 'field', 'entity_test', 'text', 'field_sql_storage');
public static $modules = array('serialization', 'system', 'entity', 'field', 'entity_test', 'text', 'filter', 'field_sql_storage');
protected function setUp() {
parent::setUp();
......
......@@ -377,7 +377,7 @@ protected function checkIntrospection($entity_type) {