diff --git a/config/schema/media_entity.schema.yml b/config/schema/media_entity.schema.yml index 1c1f720..2d88660 100644 --- a/config/schema/media_entity.schema.yml +++ b/config/schema/media_entity.schema.yml @@ -67,3 +67,13 @@ field.formatter.settings.media_thumbnail: image_style: type: string label: 'Image style' + +media_entity.bundle.field_aware_type: + type: mapping + mapping: + source_field: + type: string + label: 'Source field' + +media_entity.bundle.type.generic: + type: media_entity.bundle.field_aware_type diff --git a/src/Annotation/MediaType.php b/src/Annotation/MediaType.php index 65a3d70..0d43633 100644 --- a/src/Annotation/MediaType.php +++ b/src/Annotation/MediaType.php @@ -40,4 +40,11 @@ class MediaType extends Plugin { */ public $description = ''; + /** + * The field types that can be used as a source field for this type. + * + * @var string[] + */ + public $allowed_field_types = []; + } diff --git a/src/Entity/MediaBundle.php b/src/Entity/MediaBundle.php index 8833f82..719e365 100644 --- a/src/Entity/MediaBundle.php +++ b/src/Entity/MediaBundle.php @@ -2,12 +2,15 @@ namespace Drupal\media_entity\Entity; -use Drupal\Core\Entity\EntityDescriptionInterface; use Drupal\Core\Config\Entity\ConfigEntityBundleBase; +use Drupal\Core\Entity\EntityDescriptionInterface; +use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\EntityWithPluginCollectionInterface; use Drupal\Core\Plugin\DefaultSingleLazyPluginCollection; +use Drupal\field\FieldStorageConfigInterface; use Drupal\media_entity\MediaBundleInterface; use Drupal\media_entity\MediaInterface; +use Drupal\media_entity\SourceFieldInterface; /** * Defines the Media bundle configuration entity. @@ -121,7 +124,7 @@ class MediaBundle extends ConfigEntityBundleBase implements MediaBundleInterface /** * Default status of this media bundle. * - * @var array + * @var bool */ public $status = TRUE; @@ -241,4 +244,72 @@ class MediaBundle extends ConfigEntityBundleBase implements MediaBundleInterface $this->new_revision = $new_revision; } + /** + * {@inheritdoc} + */ + public function preSave(EntityStorageInterface $storage) { + parent::preSave($storage); + + // If the handler uses a source field, we'll need to store its name before + // saving. We'd need to double-save if we did this in postSave(). + $handler = $this->getType(); + if ($handler instanceof SourceFieldInterface) { + $storage = $handler->getSourceField($this)->getFieldStorageDefinition(); + // If the field storage is a new (unsaved) config entity, save it. + if ($storage instanceof FieldStorageConfigInterface && $storage->isNew()) { + $storage->save(); + } + // Store the field name. We always want to update this value because the + // field name may have changed, or a new field may have been created, + // depending on the user's actions or the handler's behavior. + $configuration = $handler->getConfiguration(); + $configuration['source_field'] = $storage->getName(); + $this->setTypeConfiguration($configuration); + } + } + + /** + * {@inheritdoc} + */ + public function postSave(EntityStorageInterface $storage, $update = TRUE) { + parent::postSave($storage, $update); + + // If the handler is using a source field, we may need to save it if it's + // new. The field storage is guaranteed to exist already because preSave() + // took care of that. + $handler = $this->getType(); + if ($handler instanceof SourceFieldInterface) { + $field = $handler->getSourceField($this); + + // If the field is new, save it and add it to this bundle's view and form + // displays. + if ($field->isNew()) { + // Ensure the field is saved correctly before adding it to the displays. + $field->save(); + + $entity_type = $field->getTargetEntityTypeId(); + $bundle = $field->getTargetBundle(); + + if ($field->isDisplayConfigurable('form')) { + // Use the default widget and settings. + $component = \Drupal::service('plugin.manager.field.widget') + ->prepareConfiguration($field->getType(), []); + + entity_get_form_display($entity_type, $bundle, 'default') + ->setComponent($field->getName(), $component) + ->save(); + } + if ($field->isDisplayConfigurable('view')) { + // Use the default formatter and settings. + $component = \Drupal::service('plugin.manager.field.formatter') + ->prepareConfiguration($field->getType(), []); + + entity_get_display($entity_type, $bundle, 'default') + ->setComponent($field->getName(), $component) + ->save(); + } + } + } + } + } diff --git a/src/MediaBundleForm.php b/src/MediaBundleForm.php index 014a538..5d310f0 100644 --- a/src/MediaBundleForm.php +++ b/src/MediaBundleForm.php @@ -373,7 +373,7 @@ class MediaBundleForm extends EntityForm { // Override the "status" base field default value, for this bundle. $fields = $this->entityFieldManager->getFieldDefinitions('media', $bundle->id()); - $media = $this->entityTypeManager->getStorage('media')->create(array('bundle' => $bundle->id())); + $media = $this->entityTypeManager->getStorage('media')->create(['bundle' => $bundle->id()]); $value = (bool) $form_state->getValue(['options', 'status']); if ($media->status->value != $value) { $fields['status']->getConfig($bundle->id())->setDefaultValue($value)->save(); diff --git a/src/MediaTypeBase.php b/src/MediaTypeBase.php index a472e7d..2922b17 100644 --- a/src/MediaTypeBase.php +++ b/src/MediaTypeBase.php @@ -3,19 +3,19 @@ namespace Drupal\media_entity; use Drupal\Component\Plugin\PluginBase; +use Drupal\Component\Utility\NestedArray; use Drupal\Core\Config\Config; use Drupal\Core\Entity\EntityFieldManagerInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; -use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; -use Drupal\Component\Utility\NestedArray; +use Drupal\Core\StringTranslation\StringTranslationTrait; use Symfony\Component\DependencyInjection\ContainerInterface; -use Drupal\Core\Form\FormStateInterface; /** * Base implementation of media type plugin. */ -abstract class MediaTypeBase extends PluginBase implements MediaTypeInterface, ContainerFactoryPluginInterface { +abstract class MediaTypeBase extends PluginBase implements SourceFieldInterface, ContainerFactoryPluginInterface { use StringTranslationTrait; /** @@ -105,7 +105,9 @@ abstract class MediaTypeBase extends PluginBase implements MediaTypeInterface, C * {@inheritdoc} */ public function defaultConfiguration() { - return []; + return [ + 'source_field' => NULL, + ]; } /** @@ -138,7 +140,28 @@ abstract class MediaTypeBase extends PluginBase implements MediaTypeInterface, C * {@inheritdoc} */ public function buildConfigurationForm(array $form, FormStateInterface $form_state) { - return []; + $options = []; + + foreach ($this->entityFieldManager->getFieldStorageDefinitions('media') as $field_name => $field) { + $allowed_type = in_array($field->getType(), $this->pluginDefinition['allowed_field_types'], TRUE); + if ($allowed_type && !$field->isBaseField()) { + $options[$field_name] = $field->getLabel(); + } + } + + // If there are existing fields to choose from, allow the user to reuse one. + if ($options) { + $form['source_field'] = [ + '#type' => 'select', + '#title' => $this->t('Field with source information.'), + '#default_value' => $this->configuration['source_field'], + '#empty_option' => $this->t('- Create -'), + '#empty_value' => NULL, + '#options' => $options, + '#description' => $this->t('The field on media items of this type that will store the source information.'), + ]; + } + return $form; } /** @@ -158,4 +181,90 @@ abstract class MediaTypeBase extends PluginBase implements MediaTypeInterface, C return 'media:' . $media->bundle() . ':' . $media->uuid(); } + /** + * {@inheritdoc} + */ + public function getSourceField(MediaBundleInterface $bundle) { + // If we don't know the name of the source field, we definitely need to + // create it. + if (empty($this->configuration['source_field'])) { + return $this->createSourceField($bundle); + } + // Even if we do know the name of the source field, there is no guarantee + // that it already exists. So check for the field and create it if needed. + $field = $this->configuration['source_field']; + $fields = $this->entityFieldManager->getFieldDefinitions('media', $bundle->id()); + return isset($fields[$field]) ? $fields[$field] : $this->createSourceField($bundle); + } + + /** + * Returns the source field storage definition. + * + * @return \Drupal\Core\Field\FieldStorageDefinitionInterface + * The field storage definition. Will be unsaved if new. + */ + protected function getSourceFieldStorage() { + // If we don't know the name of the source field, we definitely need to + // create its storage. + if (empty($this->configuration['source_field'])) { + return $this->createSourceFieldStorage(); + } + // Even if we do know the name of the source field, we cannot guarantee that + // its storage exists. So check for the storage and create it if needed. + $field = $this->configuration['source_field']; + $fields = $this->entityFieldManager->getFieldStorageDefinitions('media'); + return isset($fields[$field]) ? $fields[$field] : $this->createSourceFieldStorage(); + } + + /** + * Creates the source field storage definition. + * + * @return \Drupal\field\FieldStorageConfigInterface + * The unsaved field storage definition. + */ + abstract protected function createSourceFieldStorage(); + + /** + * Creates the source field definition for a bundle. + * + * @param \Drupal\media_entity\MediaBundleInterface $bundle + * The bundle. + * + * @return \Drupal\field\FieldConfigInterface + * The unsaved field definition. The field storage definition, if new, + * should also be unsaved. + */ + abstract protected function createSourceField(MediaBundleInterface $bundle); + + /** + * Determine the name of the source field. + * + * @return string + * The source field name. If one is already stored in configuration, it is + * returned. Otherwise, a new, unused one is generated. + */ + protected function getSourceFieldName() { + if ($this->configuration['source_field']) { + return $this->configuration['source_field']; + } + + $base_id = 'field_media_' . $this->getPluginId(); + $tries = 0; + $storage = $this->entityTypeManager->getStorage('field_storage_config'); + + // Iterate at least once, until no field with the generated ID is found. + do { + $id = $base_id; + // If we've tried before, increment and append the suffix. + if ($tries) { + $id .= '_' . $tries; + } + $field = $storage->load('media.' . $id); + $tries++; + } + while ($field); + + return $id; + } + } diff --git a/src/MediaTypeInterface.php b/src/MediaTypeInterface.php index 2a171c2..767217f 100644 --- a/src/MediaTypeInterface.php +++ b/src/MediaTypeInterface.php @@ -2,8 +2,8 @@ namespace Drupal\media_entity; -use Drupal\Component\Plugin\PluginInspectionInterface; use Drupal\Component\Plugin\ConfigurablePluginInterface; +use Drupal\Component\Plugin\PluginInspectionInterface; use Drupal\Core\Plugin\PluginFormInterface; /** diff --git a/src/Plugin/Action/DeleteMedia.php b/src/Plugin/Action/DeleteMedia.php index b2a98d2..4a5a48e 100644 --- a/src/Plugin/Action/DeleteMedia.php +++ b/src/Plugin/Action/DeleteMedia.php @@ -84,7 +84,7 @@ class DeleteMedia extends ActionBase implements ContainerFactoryPluginInterface * {@inheritdoc} */ public function execute($object = NULL) { - $this->executeMultiple(array($object)); + $this->executeMultiple([$object]); } /** diff --git a/src/Plugin/MediaEntity/Type/Generic.php b/src/Plugin/MediaEntity/Type/Generic.php index b83ba9d..7fe313e 100644 --- a/src/Plugin/MediaEntity/Type/Generic.php +++ b/src/Plugin/MediaEntity/Type/Generic.php @@ -3,6 +3,7 @@ namespace Drupal\media_entity\Plugin\MediaEntity\Type; use Drupal\Core\Form\FormStateInterface; +use Drupal\media_entity\MediaBundleInterface; use Drupal\media_entity\MediaInterface; use Drupal\media_entity\MediaTypeBase; @@ -12,7 +13,8 @@ use Drupal\media_entity\MediaTypeBase; * @MediaType( * id = "generic", * label = @Translation("Generic media"), - * description = @Translation("Generic media type.") + * description = @Translation("Generic media type."), + * allowed_field_types = {"string"} * ) */ class Generic extends MediaTypeBase { @@ -42,6 +44,8 @@ class Generic extends MediaTypeBase { * {@inheritdoc} */ public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form = parent::buildConfigurationForm($form, $form_state); + $form['text'] = [ '#type' => 'markup', '#markup' => $this->t("This type provider doesn't need configuration."), @@ -50,4 +54,32 @@ class Generic extends MediaTypeBase { return $form; } + /** + * {@inheritdoc} + */ + protected function createSourceFieldStorage() { + return $this->entityTypeManager + ->getStorage('field_storage_config') + ->create([ + 'entity_type' => 'media', + 'field_name' => $this->getSourceFieldName(), + // Strings are harmless, inoffensive puppies: a good choice for a + // generic media type. + 'type' => 'string', + ]); + } + + /** + * {@inheritdoc} + */ + protected function createSourceField(MediaBundleInterface $bundle) { + /** @var \Drupal\field\FieldConfigInterface $field */ + return $this->entityTypeManager + ->getStorage('field_config') + ->create([ + 'field_storage' => $this->getSourceFieldStorage(), + 'bundle' => $bundle->id(), + ]); + } + } diff --git a/src/SourceFieldInterface.php b/src/SourceFieldInterface.php new file mode 100644 index 0000000..6c245d7 --- /dev/null +++ b/src/SourceFieldInterface.php @@ -0,0 +1,20 @@ + 'This is default value.', ]; } @@ -39,6 +40,8 @@ class TestType extends Generic { * {@inheritdoc} */ public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form = parent::buildConfigurationForm($form, $form_state); + $form['test_config_value'] = [ '#type' => 'textfield', '#title' => $this->t('Test config value'), diff --git a/tests/src/FunctionalJavascript/BundleCreationTest.php b/tests/src/FunctionalJavascript/BundleCreationTest.php new file mode 100644 index 0000000..5e6eac9 --- /dev/null +++ b/tests/src/FunctionalJavascript/BundleCreationTest.php @@ -0,0 +1,108 @@ +drupalGet('admin/structure/media/add'); + $page = $this->getSession()->getPage(); + + // Fill in a label to the bundle. + $page->fillField('label', $label); + // Wait for machine name generation. Default: waitUntilVisible(), does not + // work properly. + $this->getSession() + ->wait(5000, "jQuery('.machine-name-value').text() === '{$bundleMachineName}'"); + + // Select our test bundle type. + $this->assertSession()->fieldExists('Type provider'); + $this->assertSession()->optionExists('Type provider', 'test_type'); + $page->selectFieldOption('Type provider', 'test_type'); + $this->assertSession()->assertWaitOnAjaxRequest(); + + $page->pressButton('Save media bundle'); + + // Check whether the source field was correctly created. + $this->drupalGet("admin/structure/media/manage/{$bundleMachineName}/fields"); + + // Check 2nd column of first data row, to be machine name for field name. + $this->assertSession() + ->elementContains('xpath', '(//table[@id="field-overview"]//tr)[2]//td[2]', 'field_media_test_type'); + // Check 3rd column of first data row, to be correct field type. + $this->assertSession() + ->elementTextContains('xpath', '(//table[@id="field-overview"]//tr)[2]//td[3]', 'Text (plain)'); + + // Check that the source field is correctly assigned to media bundle. + $this->drupalGet("admin/structure/media/manage/{$bundleMachineName}"); + + $this->assertSession() + ->fieldValueEquals('type_configuration[test_type][source_field]', 'field_media_test_type'); + } + + /** + * Test creation of media bundle, reusing an existing source field. + */ + public function testBundleCreationReuseSourceField() { + // Create a new bundle, which should create a new field we can reuse. + $this->drupalGet('/admin/structure/media/add'); + $page = $this->getSession()->getPage(); + $page->fillField('label', 'Pastafazoul'); + $this->getSession() + ->wait(5000, "jQuery('.machine-name-value').text() === 'pastafazoul'"); + $page->selectFieldOption('Type provider', 'generic'); + $this->assertSession()->assertWaitOnAjaxRequest(); + $page->pressButton('Save media bundle'); + + $label = 'Bundle reusing Default Field'; + $bundleMachineName = str_replace(' ', '_', strtolower($label)); + + $this->drupalGet('admin/structure/media/add'); + $page = $this->getSession()->getPage(); + + // Fill in a label to the bundle. + $page->fillField('label', $label); + + // Wait for machine name generation. Default: waitUntilVisible(), does not + // work properly. + $this->getSession() + ->wait(5000, "jQuery('.machine-name-value').text() === '{$bundleMachineName}'"); + + // Select our test bundle type. + $this->assertSession()->fieldExists('Type provider'); + $this->assertSession()->optionExists('Type provider', 'test_type'); + $page->selectFieldOption('Type provider', 'test_type'); + $this->assertSession()->assertWaitOnAjaxRequest(); + // Select the existing field for re-use. + $page->selectFieldOption('type_configuration[test_type][source_field]', 'field_media_generic'); + $page->pressButton('Save media bundle'); + + // Check that there are not fields created. + $this->drupalGet("admin/structure/media/manage/{$bundleMachineName}/fields"); + // The reused field should be present... + $this->assertSession()->pageTextContains('field_media_generic'); + // ...not a new, unique one. + $this->assertSession()->pageTextNotContains('field_media_generic_1'); + } + +} diff --git a/tests/src/FunctionalJavascript/MediaUiJavascriptTest.php b/tests/src/FunctionalJavascript/MediaUiJavascriptTest.php index c8219fe..4cd1c7e 100644 --- a/tests/src/FunctionalJavascript/MediaUiJavascriptTest.php +++ b/tests/src/FunctionalJavascript/MediaUiJavascriptTest.php @@ -140,6 +140,11 @@ class MediaUiJavascriptTest extends MediaEntityJavascriptTestBase { $this->assertFalse($loaded_bundle->getStatus()); $this->assertEquals($loaded_bundle->field_map, ['field_1' => 'name']); + // We need to clear the statically cached field definitions to account for + // fields that have been created by API calls in this test, since they exist + // in a separate memory space from the web server. + $this->container->get('entity_field.manager')->clearCachedFieldDefinitions(); + // Test that a media being created with default status to "FALSE" will be // created unpublished. /** @var \Drupal\media_entity\MediaInterface $unpublished_media */ diff --git a/tests/src/Kernel/BasicCreationTest.php b/tests/src/Kernel/BasicCreationTest.php index 306b553..90514c6 100644 --- a/tests/src/Kernel/BasicCreationTest.php +++ b/tests/src/Kernel/BasicCreationTest.php @@ -2,6 +2,7 @@ namespace Drupal\Tests\media_entity\Kernel; +use Drupal\field\Entity\FieldConfig; use Drupal\KernelTests\KernelTestBase; use Drupal\media_entity\Entity\Media; use Drupal\media_entity\Entity\MediaBundle; @@ -79,7 +80,7 @@ class BasicCreationTest extends KernelTestBase { $this->assertEquals($test_bundle->get('label'), 'Test bundle', 'Could not assure the correct bundle label.'); $this->assertEquals($test_bundle->get('description'), 'Test bundle.', 'Could not assure the correct bundle description.'); $this->assertEquals($test_bundle->get('type'), 'generic', 'Could not assure the correct bundle plugin type.'); - $this->assertEquals($test_bundle->get('type_configuration'), [], 'Could not assure the correct plugin configuration.'); + $this->assertEquals($test_bundle->get('type_configuration'), ['source_field' => 'field_media_generic_1'], 'Could not assure the correct plugin configuration.'); $this->assertEquals($test_bundle->get('field_map'), [], 'Could not assure the correct field map.'); } @@ -112,4 +113,51 @@ class BasicCreationTest extends KernelTestBase { $this->assertEquals($media->label(), $expected_name, 'The media was not created with a default name.'); } + /** + * Tests creating and updating bundles programmatically. + */ + public function testProgrammaticBundleManipulation() { + // Creating a bundle programmatically without specifying a source field + // should create one automagically. + /** @var FieldConfig $field */ + $field = $this->testBundle->getType()->getSourceField($this->testBundle); + $this->assertInstanceOf(FieldConfig::class, $field); + $this->assertEquals('field_media_generic', $field->getName()); + $this->assertFalse($field->isNew()); + + // Saving with a non-existent source field should create it. + $this->testBundle->setTypeConfiguration([ + 'source_field' => 'field_magick', + ]); + $this->testBundle->save(); + $field = $this->testBundle->getType()->getSourceField($this->testBundle); + $this->assertInstanceOf(FieldConfig::class, $field); + $this->assertEquals('field_magick', $field->getName()); + $this->assertFalse($field->isNew()); + + // Trying to save without a source field should create a new, de-duped one. + $this->testBundle->setTypeConfiguration([]); + $this->testBundle->save(); + $field = $this->testBundle->getType()->getSourceField($this->testBundle); + $this->assertInstanceOf(FieldConfig::class, $field); + $this->assertEquals('field_media_generic_1', $field->getName()); + $this->assertFalse($field->isNew()); + + // Trying to reuse an existing field should, well, reuse the existing field. + $this->testBundle->setTypeConfiguration([ + 'source_field' => 'field_magick', + ]); + $this->testBundle->save(); + $field = $this->testBundle->getType()->getSourceField($this->testBundle); + $this->assertInstanceOf(FieldConfig::class, $field); + $this->assertEquals('field_magick', $field->getName()); + $this->assertFalse($field->isNew()); + // No new de-duped fields should have been created. + $duplicates = FieldConfig::loadMultiple([ + 'media.' . $this->testBundle->id() . '.field_magick_1', + 'media.' . $this->testBundle->id() . '.field_media_generic_2', + ]); + $this->assertEmpty($duplicates); + } + }