diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php index e92a3d6..f664351 100644 --- a/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php +++ b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php @@ -39,6 +39,7 @@ class NodeHalJsonAnonTest extends NodeResourceTestBase { 'promote', 'sticky', 'revision_timestamp', + 'path', 'revision_uid', ]; @@ -54,7 +55,7 @@ protected function getExpectedNormalizedEntity() { return $normalization + [ '_links' => [ 'self' => [ - 'href' => $this->baseUrl . '/node/1?_format=hal_json', + 'href' => $this->baseUrl . '/llama?_format=hal_json', ], 'type' => [ 'href' => $this->baseUrl . '/rest/type/node/camelids', diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonAnonTest.php index ff952ea..73a1549 100644 --- a/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonAnonTest.php +++ b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonAnonTest.php @@ -40,7 +40,7 @@ protected function getExpectedNormalizedEntity() { return $normalization + [ '_links' => [ 'self' => [ - 'href' => $this->baseUrl . '/taxonomy/term/1?_format=hal_json', + 'href' => $this->baseUrl . '/llama?_format=hal_json', ], 'type' => [ 'href' => $this->baseUrl . '/rest/type/taxonomy_term/camelids', diff --git a/core/modules/path/path.module b/core/modules/path/path.module index a724b77..4faf8a6 100644 --- a/core/modules/path/path.module +++ b/core/modules/path/path.module @@ -4,7 +4,7 @@ * @file * Enables users to rename URLs. */ - +use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Field\BaseFieldDefinition; use Drupal\Core\Form\FormStateInterface; @@ -76,3 +76,17 @@ function path_entity_base_field_info(EntityTypeInterface $entity_type) { return $fields; } } + +/** + * Implements hook_entity_translation_create(). + */ +function path_entity_translation_create(ContentEntityInterface $translation) { + foreach ($translation->getFieldDefinitions() as $field_name => $field_definition) { + if ($field_definition->getType() === 'path' && $translation->get($field_name)->pid) { + // If there are values and a PID, update the langcode and unset the pid + // to save this as a new alias. + $translation->get($field_name)->langcode = $translation->language()->getId(); + $translation->get($field_name)->pid = NULL; + } + } +} diff --git a/core/modules/path/src/Plugin/Field/FieldType/PathFieldItemList.php b/core/modules/path/src/Plugin/Field/FieldType/PathFieldItemList.php index ee03361..52db66b 100644 --- a/core/modules/path/src/Plugin/Field/FieldType/PathFieldItemList.php +++ b/core/modules/path/src/Plugin/Field/FieldType/PathFieldItemList.php @@ -34,4 +34,34 @@ public function delete() { \Drupal::service('path.alias_storage')->delete($conditions); } + /** + * {@inheritdoc} + */ + public function getValue($include_computed = FALSE) { + $this->ensureLoaded(); + return parent::getValue($include_computed); + } + + /** + * {@inheritdoc} + */ + public function isEmpty() { + $this->ensureLoaded(); + return parent::isEmpty(); + } + + /** + * Automatically create the first item for computed fields. + * + * This ensures that ::getValue() and ::isEmpty() calls will behave like a + * non-computed field. + * + * @todo: Move this to the base class in https://www.drupal.org/node/2392845. + */ + protected function ensureLoaded() { + if (!isset($this->list[0]) && $this->definition->isComputed()) { + $this->list[0] = $this->createItem(0); + } + } + } diff --git a/core/modules/path/src/Plugin/Field/FieldType/PathItem.php b/core/modules/path/src/Plugin/Field/FieldType/PathItem.php index 4d8da7a..4cbc8f2 100644 --- a/core/modules/path/src/Plugin/Field/FieldType/PathItem.php +++ b/core/modules/path/src/Plugin/Field/FieldType/PathItem.php @@ -23,6 +23,13 @@ class PathItem extends FieldItemBase { /** + * Whether the alias has been loaded from the alias storage service yet. + * + * @var bool + */ + protected $isLoaded = FALSE; + + /** * {@inheritdoc} */ public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) { @@ -30,12 +37,22 @@ public static function propertyDefinitions(FieldStorageDefinitionInterface $fiel ->setLabel(t('Path alias')); $properties['pid'] = DataDefinition::create('integer') ->setLabel(t('Path id')); + $properties['langcode'] = DataDefinition::create('string') + ->setLabel(t('Language Code')); return $properties; } /** * {@inheritdoc} */ + public function __get($name) { + $this->ensureLoaded(); + return parent::__get($name); + } + + /** + * {@inheritdoc} + */ public static function schema(FieldStorageDefinitionInterface $field_definition) { return []; } @@ -43,6 +60,22 @@ public static function schema(FieldStorageDefinitionInterface $field_definition) /** * {@inheritdoc} */ + public function getValue() { + $this->ensureLoaded(); + return parent::getValue(); + } + + /** + * {@inheritdoc} + */ + public function isEmpty() { + $this->ensureLoaded(); + return parent::isEmpty(); + } + + /** + * {@inheritdoc} + */ public function preSave() { $this->alias = trim($this->alias); } @@ -50,6 +83,28 @@ public function preSave() { /** * {@inheritdoc} */ + public function __set($name, $value) { + // Also ensure that existing values are loaded when setting a value, this + // ensures that it is possible to set a new value immediately after loading + // an entity. + $this->ensureLoaded(); + parent::__set($name, $value); + } + + /** + * {@inheritdoc} + */ + public function set($property_name, $value, $notify = TRUE) { + // Also ensure that existing values are loaded when setting a value, this + // ensures that it is possible to set a new value immediately after loading + // an entity. + $this->ensureLoaded(); + return parent::set($property_name, $value, $notify); + } + + /** + * {@inheritdoc} + */ public function postSave($update) { if (!$update) { if ($this->alias) { @@ -88,4 +143,38 @@ public static function mainPropertyName() { return 'alias'; } + /** + * Ensures the alias properties are loaded if available. + * + * This ensures that the properties will always be loaded and act like + * non-computed fields when calling ::__get() and getValue(). + * + * @todo: Determine if this should be moved to the base class in + * https://www.drupal.org/node/2392845. + */ + protected function ensureLoaded() { + if (!$this->isLoaded) { + $entity = $this->getEntity(); + if (!$entity->isNew()) { + // @todo Support loading languge neutral aliases in + // https://www.drupal.org/node/2511968. + $alias = \Drupal::service('path.alias_storage')->load([ + 'source' => '/' . $entity->toUrl()->getInternalPath(), + 'langcode' => $this->getLangcode(), + ]); + if ($alias) { + $this->setValue($alias); + } + else { + // If there is no existing alias, default the langcode to the current + // language. + // @todo Set the langcode to not specified for untranslatable fields + // in https://www.drupal.org/node/2689459. + $this->langcode = $this->getLangcode(); + } + } + $this->isLoaded = TRUE; + } + } + } diff --git a/core/modules/path/src/Plugin/Field/FieldWidget/PathWidget.php b/core/modules/path/src/Plugin/Field/FieldWidget/PathWidget.php index b31af46..e21115f 100644 --- a/core/modules/path/src/Plugin/Field/FieldWidget/PathWidget.php +++ b/core/modules/path/src/Plugin/Field/FieldWidget/PathWidget.php @@ -5,7 +5,6 @@ use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\WidgetBase; use Drupal\Core\Form\FormStateInterface; -use Drupal\Core\Language\LanguageInterface; use Symfony\Component\Validator\ConstraintViolationInterface; /** @@ -26,23 +25,6 @@ class PathWidget extends WidgetBase { */ public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) { $entity = $items->getEntity(); - $path = []; - if (!$entity->isNew()) { - $conditions = ['source' => '/' . $entity->urlInfo()->getInternalPath()]; - if ($items->getLangcode() != LanguageInterface::LANGCODE_NOT_SPECIFIED) { - $conditions['langcode'] = $items->getLangcode(); - } - $path = \Drupal::service('path.alias_storage')->load($conditions); - if ($path === FALSE) { - $path = []; - } - } - $path += [ - 'pid' => NULL, - 'source' => !$entity->isNew() ? '/' . $entity->urlInfo()->getInternalPath() : NULL, - 'alias' => '', - 'langcode' => $items->getLangcode(), - ]; $element += [ '#element_validate' => [[get_class($this), 'validateFormElement']], @@ -50,22 +32,22 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen $element['alias'] = [ '#type' => 'textfield', '#title' => $element['#title'], - '#default_value' => $path['alias'], + '#default_value' => $items[$delta]->alias, '#required' => $element['#required'], '#maxlength' => 255, '#description' => $this->t('Specify an alternative path by which this data can be accessed. For example, type "/about" when writing an about page.'), ]; $element['pid'] = [ '#type' => 'value', - '#value' => $path['pid'], - ]; + '#value' => $items[$delta]->pid, + ); $element['source'] = [ '#type' => 'value', - '#value' => $path['source'], - ]; + '#value' => !$entity->isNew() ? '/' . $entity->toUrl()->getInternalPath() : NULL, + ]; $element['langcode'] = [ '#type' => 'value', - '#value' => $path['langcode'], + '#value' => $items[$delta]->langcode, ]; return $element; } diff --git a/core/modules/path/tests/src/Kernel/PathItemTest.php b/core/modules/path/tests/src/Kernel/PathItemTest.php new file mode 100644 index 0000000..769fa3a --- /dev/null +++ b/core/modules/path/tests/src/Kernel/PathItemTest.php @@ -0,0 +1,154 @@ +installEntitySchema('node'); + $this->installEntitySchema('user'); + + $this->installSchema('node', ['node_access']); + + $node_type = NodeType::create(['type' => 'foo']); + $node_type->save(); + + $this->installConfig(['language']); + ConfigurableLanguage::createFromLangcode('de')->save(); + } + + /** + * Test creating, loading, updating and deleting aliases through PathItem. + */ + public function testPathItem() { + + /** @var \Drupal\Core\Path\AliasStorageInterface $alias_storage */ + $alias_storage = \Drupal::service('path.alias_storage'); + + $node_storage = \Drupal::entityTypeManager()->getStorage('node'); + + $node = Node::create([ + 'title' => 'Testing create()', + 'type' => 'foo', + 'path' => ['alias' => '/foo'], + ]); + $this->assertFalse($node->get('path')->isEmpty()); + $this->assertEquals('/foo', $node->get('path')->alias); + + $node->save(); + $this->assertFalse($node->get('path')->isEmpty()); + $this->assertEquals('/foo', $node->get('path')->alias); + + $stored_alias = $alias_storage->lookupPathAlias('/' . $node->toUrl()->getInternalPath(), $node->language()->getId()); + $this->assertEquals('/foo', $stored_alias); + + $node_storage->resetCache(); + + /** @var \Drupal\node\NodeInterface $loaded_node */ + $loaded_node = $node_storage->load($node->id()); + $this->assertFalse($loaded_node->get('path')->isEmpty()); + $this->assertEquals('/foo', $loaded_node->get('path')->alias); + + $node_storage->resetCache(); + $loaded_node = $node_storage->load($node->id()); + $values = $loaded_node->get('path')->getValue(); + $this->assertEquals('/foo', $values[0]['alias']); + + $node_storage->resetCache(); + $loaded_node = $node_storage->load($node->id()); + $this->assertEquals('/foo', $loaded_node->path->alias); + + // Add a translation, verify it is being saved as expected. + $translation = $loaded_node->addTranslation('de', $loaded_node->toArray()); + $translation->get('path')->alias = '/furchtbar'; + $translation->save(); + + // Assert the alias on the English node, the German translation and the + // stored aliases. + $node_storage->resetCache(); + $loaded_node = $node_storage->load($node->id()); + $this->assertEquals('/foo', $loaded_node->path->alias); + $translation = $loaded_node->getTranslation('de'); + $this->assertEquals('/furchtbar', $translation->path->alias); + + $stored_alias = $alias_storage->lookupPathAlias('/' . $node->toUrl()->getInternalPath(), $node->language()->getId()); + $this->assertEquals('/foo', $stored_alias); + $stored_alias = $alias_storage->lookupPathAlias('/' . $node->toUrl()->getInternalPath(), $translation->language()->getId()); + $this->assertEquals('/furchtbar', $stored_alias); + + $loaded_node->get('path')->alias = '/bar'; + $this->assertFalse($loaded_node->get('path')->isEmpty()); + $this->assertEquals('/bar', $loaded_node->get('path')->alias); + + $loaded_node->save(); + $this->assertFalse($loaded_node->get('path')->isEmpty()); + $this->assertEquals('/bar', $loaded_node->get('path')->alias); + + $node_storage->resetCache(); + $loaded_node = $node_storage->load($node->id()); + $this->assertFalse($loaded_node->get('path')->isEmpty()); + $this->assertEquals('/bar', $loaded_node->get('path')->alias); + + $loaded_node->get('path')->alias = '/bar'; + $this->assertFalse($loaded_node->get('path')->isEmpty()); + $this->assertEquals('/bar', $loaded_node->get('path')->alias); + + $loaded_node->save(); + $this->assertFalse($loaded_node->get('path')->isEmpty()); + $this->assertEquals('/bar', $loaded_node->get('path')->alias); + + $stored_alias = $alias_storage->lookupPathAlias('/' . $node->toUrl()->getInternalPath(), $node->language()->getId()); + $this->assertEquals('/bar', $stored_alias); + + $old_alias = $alias_storage->lookupPathSource('/foo', $node->language()->getId()); + $this->assertFalse($old_alias); + + // Reload the node to make sure that it is possibly to set a value + // immediately after loading. + $node_storage->resetCache(); + $loaded_node = $node_storage->load($node->id()); + $loaded_node->get('path')->alias = '/foobar'; + $loaded_node->save(); + + $node_storage->resetCache(); + $loaded_node = $node_storage->load($node->id()); + $this->assertFalse($loaded_node->get('path')->isEmpty()); + $this->assertEquals('/foobar', $loaded_node->get('path')->alias); + $stored_alias = $alias_storage->lookupPathAlias('/' . $node->toUrl()->getInternalPath(), $node->language()->getId()); + $this->assertEquals('/foobar', $stored_alias); + + $old_alias = $alias_storage->lookupPathSource('/bar', $node->language()->getId()); + $this->assertFalse($old_alias); + + $loaded_node->get('path')->alias = ''; + $this->assertEquals('', $loaded_node->get('path')->alias); + + $loaded_node->save(); + + $stored_alias = $alias_storage->lookupPathAlias('/' . $node->toUrl()->getInternalPath(), $node->language()->getId()); + $this->assertFalse($stored_alias); + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php index cfbd971..8c80695 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php @@ -12,7 +12,7 @@ /** * {@inheritdoc} */ - public static $modules = ['node']; + public static $modules = ['node', 'path']; /** * {@inheritdoc} @@ -30,6 +30,7 @@ 'sticky', 'revision_timestamp', 'revision_uid', + 'path', ]; /** @@ -49,6 +50,10 @@ protected function setUpAuthorization($method) { $this->grantPermissionsToTestedRole(['access content', 'create camelids content']); break; case 'PATCH': + // Do not grant the 'create url aliases' permission to test the case + // when the path field is protected/not accessible, see + // \Drupal\Tests\rest\Functional\EntityResource\Term\TermResourceTestBase + // for a positive test. $this->grantPermissionsToTestedRole(['access content', 'edit any camelids content']); break; case 'DELETE': @@ -77,6 +82,7 @@ protected function createEntity() { ->setCreatedTime(123456789) ->setChangedTime(123456789) ->setRevisionCreationTime(123456789) + ->set('path', '/llama') ->save(); return $node; @@ -171,6 +177,13 @@ protected function getExpectedNormalizedEntity() { ], ], 'revision_log' => [], + 'path' => [ + [ + 'alias' => '/llama', + 'pid' => 1, + 'langcode' => 'en', + ], + ], ]; } diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php index 698451b..f773eee 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php @@ -11,7 +11,7 @@ /** * {@inheritdoc} */ - public static $modules = ['taxonomy']; + public static $modules = ['taxonomy', 'path']; /** * {@inheritdoc} @@ -41,8 +41,12 @@ protected function setUpAuthorization($method) { case 'POST': case 'PATCH': case 'DELETE': + // Grant the 'create url aliases' permission to test the case when + // the path field is accessible, see + // \Drupal\Tests\rest\Functional\EntityResource\Node\NodeResourceTestBase + // for a negative test. // @todo Update once https://www.drupal.org/node/2824408 lands. - $this->grantPermissionsToTestedRole(['administer taxonomy']); + $this->grantPermissionsToTestedRole(['administer taxonomy', 'create url aliases']); break; } } @@ -64,7 +68,8 @@ protected function createEntity() { // Create a "Llama" taxonomy term. $term = Term::create(['vid' => $vocabulary->id()]) ->setName('Llama') - ->setChangedTime(123456789); + ->setChangedTime(123456789) + ->set('path', '/llama'); $term->save(); return $term; @@ -116,6 +121,13 @@ protected function getExpectedNormalizedEntity() { 'value' => TRUE, ], ], + 'path' => [ + [ + 'alias' => '/llama', + 'pid' => 1, + 'langcode' => 'en', + ], + ], ]; }