Commit 068edfc1 authored by alexpott's avatar alexpott

Issue #2395831 by dawehner, fago, martin107, cafuego, YesCT, plach, jibran,...

Issue #2395831 by dawehner, fago, martin107, cafuego, YesCT, plach, jibran, larowlan, Wim Leers, effulgentsia, klausi: Entity forms skip validation of fields that are not in the EntityFormDisplay
parent 700158c9
......@@ -284,7 +284,8 @@ public function preSaveRevision(EntityStorageInterface $storage, \stdClass $reco
* {@inheritdoc}
*/
public function validate() {
return $this->getTypedData()->validate();
$violations = $this->getTypedData()->validate();
return new EntityConstraintViolationList($this, iterator_to_array($violations));
}
/**
......
......@@ -75,8 +75,17 @@ public function form(array $form, FormStateInterface $form_state) {
* https://www.drupal.org/node/2015613.
*/
public function validate(array $form, FormStateInterface $form_state) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $this->buildEntity($form, $form_state);
$this->getFormDisplay($form_state)->validateFormValues($entity, $form, $form_state);
$violations = $entity->validate();
// Remove violations of inaccessible fields and not edited fields.
$violations
->filterByFieldAccess($this->currentUser())
->filterByFields(array_diff(array_keys($entity->getFieldDefinitions()), $this->getEditedFieldNames($form_state)));
$this->flagViolations($violations, $form, $form_state);
// @todo Remove this.
// Execute legacy global validation handlers.
......@@ -85,6 +94,54 @@ public function validate(array $form, FormStateInterface $form_state) {
return $entity;
}
/**
* Gets the names of all fields edited in the form.
*
* If the entity form customly adds some fields to the form (i.e. without
* using the form display), it needs to add its fields here and override
* flagViolations() for displaying the violations.
*
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*
* @return string[]
* An array of field names.
*/
protected function getEditedFieldNames(FormStateInterface $form_state) {
return array_keys($this->getFormDisplay($form_state)->getComponents());
}
/**
* Flags violations for the current form.
*
* If the entity form customly adds some fields to the form (i.e. without
* using the form display), it needs to add its fields to array returned by
* getEditedFieldNames() and overwrite this method in order to show any
* violations for those fields; e.g.:
* @code
* foreach ($violations->getByField('name') as $violation) {
* $form_state->setErrorByName('name', $violation->getMessage());
* }
* parent::flagViolations($violations, $form, $form_state);
* @endcode
*
* @param \Drupal\Core\Entity\EntityConstraintViolationListInterface $violations
* The violations to flag.
* @param array $form
* A nested array of form elements comprising the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*/
protected function flagViolations(EntityConstraintViolationListInterface $violations, array $form, FormStateInterface $form_state) {
// Flag entity level violations.
foreach ($violations->getEntityViolations() as $violation) {
/** @var \Symfony\Component\Validator\ConstraintViolationInterface $violation */
$form_state->setErrorByName('', $violation->getMessage());
}
// Let the form display flag violations of its fields.
$this->getFormDisplay($form_state)->flagWidgetsErrorsFromViolations($violations, $form, $form_state);
}
/**
* Initializes the form state and the entity before the first form build.
*
......
......@@ -7,6 +7,7 @@
namespace Drupal\Core\Entity\Display;
use Drupal\Core\Entity\EntityConstraintViolationListInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Form\FormStateInterface;
......@@ -100,11 +101,42 @@ interface EntityFormDisplayInterface extends EntityDisplayInterface {
*/
public function buildForm(FieldableEntityInterface $entity, array &$form, FormStateInterface $form_state);
/**
* Extracts field values from the submitted widget values into the entity.
*
* This accounts for drag-and-drop reordering of field values, and filtering
* of empty values.
*
* @param \Drupal\Core\Entity\FieldableEntityInterface $entity
* The entity.
* @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 \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
*
* @return array
* An array whose keys and values are the keys of the top-level entries in
* $form_state->getValues() that have been processed. The remaining entries,
* if any, do not correspond to widgets and should be extracted manually by
* the caller if needed.
*/
public function extractFormValues(FieldableEntityInterface $entity, array &$form, FormStateInterface $form_state);
/**
* Validates submitted widget values and sets the corresponding form errors.
*
* There are two levels of validation for fields in forms: widget validation
* and field validation.
* This method invokes entity validation and takes care of flagging them on
* the form. This is particularly useful when all elements on the form are
* managed by the form display.
*
* As an alternative, entity validation can be invoked separately such that
* some violations can be flagged manually. In that case
* \Drupal\Core\Entity\Display\EntityFormDisplayInterface::flagViolations()
* must be used for flagging violations related to the form display.
*
* Note that there are two levels of validation for fields in forms: widget
* validation and field validation:
* - Widget validation steps are specific to a given widget's own form
* structure and UI metaphors. They are executed during normal form
* validation, usually through Form API's #element_validate property.
......@@ -112,12 +144,9 @@ public function buildForm(FieldableEntityInterface $entity, array &$form, FormSt
* extraction of proper field values from the submitted form input.
* - If no form / widget errors were reported for the field, field validation
* steps are performed according to the "constraints" specified by the
* field definition. Those are independent of the specific widget being
* used in a given form, and are also performed on REST entity submissions.
*
* This function performs field validation in the context of a form submission.
* It reports field constraint violations as form errors on the correct form
* elements.
* field definition as part of the entity validation. That validation is
* independent of the specific widget being used in a given form, and is
* also performed on REST entity submissions.
*
* @param \Drupal\Core\Entity\FieldableEntityInterface $entity
* The entity.
......@@ -130,25 +159,26 @@ public function buildForm(FieldableEntityInterface $entity, array &$form, FormSt
public function validateFormValues(FieldableEntityInterface $entity, array &$form, FormStateInterface $form_state);
/**
* Extracts field values from the submitted widget values into the entity.
* Flags entity validation violations as form errors.
*
* This accounts for drag-and-drop reordering of field values, and filtering
* of empty values.
* This method processes all violations passed, thus any violations not
* related to fields of the form display must be processed before this method
* is invoked.
*
* @param \Drupal\Core\Entity\FieldableEntityInterface $entity
* The entity.
* The method flags constraint violations related to fields shown on the
* form as form errors on the correct form elements. Possibly pre-existing
* violations of hidden fields (so fields not appearing in the display) are
* ignored. Other, non-field related violations are passed through and set as
* form errors according to the property path of the violations.
*
* @param \Drupal\Core\Entity\EntityConstraintViolationListInterface $violations
* The violations to flag.
* @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 \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
*
* @return array
* An array whose keys and values are the keys of the top-level entries in
* $form_state->getValues() that have been processed. The remaining entries,
* if any, do not correspond to widgets and should be extracted manually by
* the caller if needed.
*/
public function extractFormValues(FieldableEntityInterface $entity, array &$form, FormStateInterface $form_state);
public function flagWidgetsErrorsFromViolations(EntityConstraintViolationListInterface $violations, array &$form, FormStateInterface $form_state);
}
......@@ -7,11 +7,15 @@
namespace Drupal\Core\Entity\Entity;
use Drupal\Core\Entity\EntityConstraintViolationListInterface;
use Drupal\Core\Entity\EntityDisplayPluginCollection;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Entity\Display\EntityFormDisplayInterface;
use Drupal\Core\Entity\EntityDisplayBase;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\ConstraintViolationListInterface;
/**
* Configuration entity that contains widget options for all components of a
......@@ -231,18 +235,88 @@ public function extractFormValues(FieldableEntityInterface $entity, array &$form
* {@inheritdoc}
*/
public function validateFormValues(FieldableEntityInterface $entity, array &$form, FormStateInterface $form_state) {
foreach ($entity as $field_name => $items) {
// Only validate the fields that actually appear in the form, and let the
// widget assign the violations to the right form elements.
$violations = $entity->validate();
$violations->filterByFieldAccess();
// Flag entity level violations.
foreach ($violations->getEntityViolations() as $violation) {
/** @var \Symfony\Component\Validator\ConstraintViolationInterface $violation */
$form_state->setErrorByName('', $violation->getMessage());
}
$this->flagWidgetsErrorsFromViolations($violations, $form, $form_state);
}
/**
* {@inheritdoc}
*/
public function flagWidgetsErrorsFromViolations(EntityConstraintViolationListInterface $violations, array &$form, FormStateInterface $form_state) {
$entity = $violations->getEntity();
foreach ($violations->getFieldNames() as $field_name) {
// Only show violations for fields that actually appear in the form, and
// let the widget assign the violations to the correct form elements.
if ($widget = $this->getRenderer($field_name)) {
$violations = $items->validate();
if (count($violations)) {
$widget->flagErrors($items, $violations, $form, $form_state);
}
$field_violations = $this->movePropertyPathViolationsRelativeToField($field_name, $violations->getByField($field_name));
$widget->flagErrors($entity->get($field_name), $field_violations, $form, $form_state);
}
}
}
/**
* Moves the property path to be relative to field level.
*
* @param string $field_name
* The field name.
* @param \Symfony\Component\Validator\ConstraintViolationListInterface $violations
* The violations.
*
* @return \Symfony\Component\Validator\ConstraintViolationList
* A new constraint violation list with the changed property path.
*/
protected function movePropertyPathViolationsRelativeToField($field_name, ConstraintViolationListInterface $violations) {
$new_violations = new ConstraintViolationList();
foreach ($violations as $violation) {
// All the logic below is necessary to change the property path of the
// violations to be relative to the item list, so like title.0.value gets
// changed to 0.value. Sadly constraints in Symfony don't have setters so
// we have to create new objects.
/** @var \Symfony\Component\Validator\ConstraintViolationInterface $violation */
// Create a new violation object with just a different property path.
$violation_path = $violation->getPropertyPath();
$path_parts = explode('.', $violation_path);
if ($path_parts[0] === $field_name) {
unset($path_parts[0]);
}
$new_path = implode('.', $path_parts);
$constraint = NULL;
$cause = NULL;
$parameters = [];
$plural = NULL;
if ($violation instanceof ConstraintViolation) {
$constraint = $violation->getConstraint();
$cause = $violation->getCause();
$parameters = $violation->getParameters();
$plural = $violation->getPlural();
}
$new_violation = new ConstraintViolation(
$violation->getMessage(),
$violation->getMessageTemplate(),
$parameters,
$violation->getRoot(),
$new_path,
$violation->getInvalidValue(),
$plural,
$violation->getCode(),
$constraint,
$cause
);
$new_violations->add($new_violation);
}
return $new_violations;
}
/**
* {@inheritdoc}
*/
......
<?php
/**
* @file
* Contains \Drupal\Core\Entity\EntityConstraintViolationList.
*/
namespace Drupal\Core\Entity;
use Drupal\Core\Entity\Plugin\Validation\Constraint\CompositeConstraintBase;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationInterface;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Implements an entity constraint violation list.
*/
class EntityConstraintViolationList extends ConstraintViolationList implements EntityConstraintViolationListInterface {
use StringTranslationTrait;
/**
* The entity that has been validated.
*
* @var \Drupal\Core\Entity\FieldableEntityInterface
*/
protected $entity;
/**
* Violations offsets of entity level violations.
*
* @var int[]|null
*/
protected $entityViolationOffsets;
/**
* Violation offsets grouped by field.
*
* Keys are field names, values are arrays of violation offsets.
*
* @var array[]|null
*/
protected $violationOffsetsByField;
/**
* {@inheritdoc}
*
* @param \Drupal\Core\Entity\FieldableEntityInterface $entity
* The entity that has been validated.
* @param array $violations
* The array of violations.
*/
public function __construct(FieldableEntityInterface $entity, array $violations = array()) {
parent::__construct($violations);
$this->entity = $entity;
}
/**
* Groups violation offsets by field and entity level.
*
* Sets the $violationOffsetsByField and $entityViolationOffsets properties.
*/
protected function groupViolationOffsets() {
if (!isset($this->violationOffsetsByField)) {
$this->violationOffsetsByField = [];
$this->entityViolationOffsets = [];
foreach ($this as $offset => $violation) {
if ($path = $violation->getPropertyPath()) {
// An example of $path might be 'title.0.value'.
list($field_name) = explode('.', $path, 2);
if ($this->entity->hasField($field_name)) {
$this->violationOffsetsByField[$field_name][$offset] = $offset;
}
}
else {
$this->entityViolationOffsets[$offset] = $offset;
}
}
}
}
/**
* {@inheritdoc}
*/
public function getEntityViolations() {
$this->groupViolationOffsets();
$violations = [];
foreach ($this->entityViolationOffsets as $offset) {
$violations[] = $this->get($offset);
}
return new static($this->entity, $violations);
}
/**
* {@inheritdoc}
*/
public function getByField($field_name) {
return $this->getByFields([$field_name]);
}
/**
* {@inheritdoc}
*/
public function getByFields(array $field_names) {
$this->groupViolationOffsets();
$violations = [];
foreach (array_intersect_key($this->violationOffsetsByField, array_flip($field_names)) as $field_name => $offsets) {
foreach ($offsets as $offset) {
$violations[] = $this->get($offset);
}
}
return new static($this->entity, $violations);
}
/**
* {@inheritdoc}
*/
public function filterByFields(array $field_names) {
$this->groupViolationOffsets();
$new_violations = [];
foreach (array_intersect_key($this->violationOffsetsByField, array_flip($field_names)) as $field_name => $offsets) {
foreach ($offsets as $offset) {
$violation = $this->get($offset);
// Take care of composite field violations and re-map them to some
// covered field if necessary.
if ($violation->getConstraint() instanceof CompositeConstraintBase) {
$covered_fields = $violation->getConstraint()->coversFields();
// Keep the composite field if it covers some remaining field and put
// a violation on some other covered field instead.
if ($remaining_fields = array_diff($covered_fields, $field_names)) {
$message_params = ['%field_name' => $field_name];
$violation = new ConstraintViolation(
$this->t('The validation failed because the value conflicts with the value in %field_name, which you cannot access.', $message_params),
'The validation failed because the value conflicts with the value in %field_name, which you cannot access.',
$message_params,
$violation->getRoot(),
reset($remaining_fields),
$violation->getInvalidValue(),
$violation->getPlural(),
$violation->getCode(),
$violation->getConstraint(),
$violation->getCause()
);
$new_violations[] = $violation;
}
}
$this->remove($offset);
}
}
foreach ($new_violations as $violation) {
$this->add($violation);
}
return $this;
}
/**
* {@inheritdoc}
*/
public function filterByFieldAccess(AccountInterface $account = NULL) {
$filtered_fields = array();
foreach ($this->getFieldNames() as $field_name) {
if (!$this->entity->get($field_name)->access('edit', $account)) {
$filtered_fields[] = $field_name;
}
}
return $this->filterByFields($filtered_fields);
}
/**
* {@inheritdoc}
*/
public function getFieldNames() {
$this->groupViolationOffsets();
return array_keys($this->violationOffsetsByField);
}
/**
* {@inheritdoc}
*/
public function getEntity() {
return $this->entity;
}
/**
* {@inheritdoc}
*/
public function add(ConstraintViolationInterface $violation) {
parent::add($violation);
$this->violationOffsetsByField = NULL;
$this->entityViolationOffsets = NULL;
}
/**
* {@inheritdoc}
*/
public function remove($offset) {
parent::remove($offset);
$this->violationOffsetsByField = NULL;
$this->entityViolationOffsets = NULL;
}
/**
* {@inheritdoc}
*/
public function set($offset, ConstraintViolationInterface $violation) {
parent::set($offset, $violation);
$this->violationOffsetsByField = NULL;
$this->entityViolationOffsets = NULL;
}
}
<?php
/**
* @file
* Contains \Drupal\Core\Entity\EntityConstraintViolationListInterface.
*/
namespace Drupal\Core\Entity;
use Drupal\Core\Session\AccountInterface;
use Symfony\Component\Validator\ConstraintViolationListInterface;
/**
* Interface for the result of entity validation.
*
* The Symfony violation list is extended with methods that allow filtering
* violations by fields and field access. Forms leverage that to skip possibly
* pre-existing violations that cannot be caused or fixed by the form.
*/
interface EntityConstraintViolationListInterface extends ConstraintViolationListInterface {
/**
* Gets violations flagged on entity level, not associated with any field.
*
* @return \Drupal\Core\Entity\EntityConstraintViolationListInterface
* A list of violations on the entity level.
*/
public function getEntityViolations();
/**
* Gets the violations of the given field.
*
* @param string $field_name
* The name of the field to get violations for.
*
* @return \Symfony\Component\Validator\ConstraintViolationListInterface
* The violations of the given field.
*/
public function getByField($field_name);
/**
* Gets the violations of the given fields.
*
* When violations should be displayed for a sub-set of visible fields only,
* this method may be used to filter the set of visible violations first.
*
* @param string[] $field_names
* The names of the fields to get violations for.
*
* @return \Drupal\Core\Entity\EntityConstraintViolationListInterface
* A list of violations of the given fields.
*/
public function getByFields(array $field_names);
/**
* Filters this violation list by the given fields.
*
* The returned object just has violations attached to the provided fields.
*
* When violations should be displayed for a sub-set of visible fields only,
* this method may be used to filter the set of visible violations first.
*
* @param string[] $field_names
* The names of the fields to filter violations for.
*
* @return $this
*/
public function filterByFields(array $field_names);
/**
* Filters this violation list to apply for accessible fields only.
*
* Violations for inaccessible fields are removed so the returned object just
* has the remaining violations.
*
* @param \Drupal\Core\Session\AccountInterface $account
* (optional) The user for which to check access, or NULL to check access
* for the current user. Defaults to NULL.
*
* @return $this
*/
public function filterByFieldAccess(AccountInterface $account = NULL);
/**
* Returns the names of all violated fields.
*
* @return string[]
* An array of field names.
*/
public function getFieldNames();
/**
* The entity which has been validated.
*
* @return \Drupal\Core\Entity\FieldableEntityInterface
* The entity object.
*/
public function getEntity();
}
......@@ -206,7 +206,7 @@ public function onChange($field_name);
/**
* Validates the currently set values.
*
* @return \Symfony\Component\Validator\ConstraintViolationListInterface
* @return \Drupal\Core\Entity\EntityConstraintViolationListInterface
* A list of constraint violations. If the list is empty, validation
* succeeded.
*/
......
......@@ -13,6 +13,7 @@
use Drupal\Core\Cache\Cache;
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\Core\Entity\ContentEntityForm;
use Drupal\Core\Entity\EntityConstraintViolationListInterface;
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Language\LanguageInterface;
......@@ -314,24 +315,22 @@ public function buildEntity(array $form, FormStateInterface $form_state) {
/**
* {@inheritdoc}
*/
public function validate(array $form, FormStateInterface $form_state) {
$comment = parent::validate($form, $form_state);
protected function getEditedFieldNames(FormStateInterface $form_state) {
return array_merge(['created', 'name'], parent::getEditedFieldNames($form_state));
}
// Customly trigger validation of manually added fields and add in
// violations.
$violations = $comment->created->validate();
foreach ($violations as $violation) {
/**
* {@inheritdoc}
*/
protected function flagViolations(EntityConstraintViolationListInterface $violations, array $form, FormStateInterface $form_state) {
// Manually flag violations of fields not handled by the form display.
foreach ($violations->getByField('created') as $violation) {
$form_state->setErrorByName('date', $violation->getMessage());
}
$violations = $comment->validate();
// Filter out violations for the name path.
foreach ($violations as $violation) {
if ($violation->getPropertyPath() === 'name') {
$form_state->setErrorByName('name', $violation->getMessage());
}
foreach ($violations->getByField('name') as $violation) {
$form_state->setErrorByName('name', $violation->getMessage());
}
return $comment;
parent::flagViolations($violations, $form, $form_state);
}
/**
......
......@@ -135,7 +135,6 @@ public function getFieldDefinitions() {
$definitions['content_translation_changed'] = BaseFieldDefinition::create('changed')
->setLabel(t('Translation changed time'))
->setDescription(t('The Unix timestamp when the translation was most recently saved.'))
->setPropertyConstraints('value', array('EntityChanged' => array()))
->setRevisionable(TRUE)
->setTranslatable(TRUE);
}
......
......@@ -737,28 +737,6 @@ function node_page_title(NodeInterface $node) {
return $node->label();
}
/**
* Finds the last time a node was changed.
*
* @param $nid
* The ID of a node.
* @param string $langcode
* (optional) The language to get the last changed time for. If omitted, the
* last changed time across all translations will be returned.
*
* @return string
* A unix timestamp indicating the last time the node was changed.
*
* @todo Remove once https://www.drupal.org/node/2002180 is resolved. It's only
* used for validation, which will be done by
* EntityChangedConstraintValidator.
*/
function node_last_changed($nid, $langcode = NULL) {
$node = \Drupal::entityManager()->getStorage('node')->loadUnchanged($nid);
$changed = $langcode ? $node->getTranslation($langcode)->getChangedTime() : $node->getChangedTimeAcrossTranslations();