diff --git a/js/yoast_seo.js b/js/yoast_seo.js index 662ae7b..e3dac3c 100644 --- a/js/yoast_seo.js +++ b/js/yoast_seo.js @@ -121,6 +121,12 @@ var self = this; + // We listen to the `updateSeoData` event on the body which is called by Drupal AJAX + // after we send our form data up for analysis. + jQuery('body').on('updateSeoData', function (e, data) { + self.setData(data); + }); + // Set up our event listener for normal form elements this.$form.change(this.handleChange.bind(this)); @@ -146,7 +152,7 @@ // We update what data we have available so that this.data is always // initialised. We also run the initializer to review existing entities. - this.refreshData(!this.config.is_new); + this.refreshData(); }; /** @@ -159,7 +165,6 @@ if ($target.attr('data-drupal-selector') === this.config.fields.focus_keyword) { // Update the keyword and re-analyze. this.setData({ keyword: $target.val() }); - this.analyze(); return; } @@ -186,7 +191,7 @@ var self = this; this.update_timeout = setTimeout(function () { self.update_timeout = false; - self.refreshData(true); + self.refreshData(); }, 500); }; @@ -207,33 +212,9 @@ * We talk to Drupal to provide all the data that the YoastSEO.js library * needs to do the analysis. */ - Orchestrator.prototype.refreshData = function (analyze) { - if (typeof analyze === 'undefined') { - analyze = false; - } - - var self = this; - - this.$form.ajaxSubmit({ - url: this.config.analysis_endpoint, - data: { - yoast_seo_preview: { - path: drupalSettings.path.currentPath, - action: this.$form.attr('action'), - method: this.$form.attr('method') - } - }, - success: function (data) { - self.setData(data); - if (analyze) { - self.analyze(); - } - }, - error: function (jqXHR, status, error) { - // TODO: Implement error handling for this endpoint. - console.log('Failed to refresh data', error); - } - }); + Orchestrator.prototype.refreshData = function () { + // Click the refresh data button to perform a Drupal AJAX submit. + this.$form.find('.yoast-seo-preview-submit-button').mousedown(); }; /** @@ -253,6 +234,9 @@ // Some things are composed of others. this.data.titleWidth = document.getElementById('snippet_title').offsetWidth; this.data.permalink = this.config.base_root + this.data.url; + + // Our data has changed so we rerun the analyzer. + this.analyze(); }; /** diff --git a/src/Controller/EntityPreviewController.php b/src/Controller/EntityPreviewController.php deleted file mode 100644 index db71926..0000000 --- a/src/Controller/EntityPreviewController.php +++ /dev/null @@ -1,169 +0,0 @@ -get('yoast_seo.entity_previewer') - ); - } - - /** - * EntityPreviewController constructor. - * - * @param \Drupal\yoast_seo\EntityPreviewer $entity_previewer - * An instance of the entity preview service. - */ - public function __construct(EntityPreviewer $entity_previewer) { - $this->entityPreviewer = $entity_previewer; - } - - /** - * Checks access for the EntityPreview based on the given entity. - * - * @param \Drupal\Core\Session\AccountInterface $account - * The session that is trying to access this route. - * - * @return \Drupal\Core\Access\AccessResultInterface - * Whether access is granted or denied. - */ - public function access(AccountInterface $account) { - // TODO: Request should be injected but that only works when #2786941 is - // fixed. We don't want a separate service because we want to cache the - // created entity. - $request = \Drupal::request(); - - // If this user can't use the analysis feature then there's no reason to - // access this route. - if (!$account->hasPermission('use yoast seo')) { - return AccessResult::forbidden(); - } - - // Retrieve the entity we'll be analysing. - $entity = $this->getEntityForRequest($request); - - // We check if the user is allowed to view the entity. - // This is safe because we don't modify any data. - if (!$entity->access('view', $account)) { - return AccessResult::forbidden(); - } - - return AccessResult::allowed(); - } - - /** - * Returns the json representation of an EntityPreview. - * - * @param \Symfony\Component\HttpFoundation\Request $request - * The request of the page. - * * data The context to use to retrieve the tokens value, - * see Drupal\Core\Utility\token::replace() - * * tokens An array of tokens to get the values for. - * - * @return \Symfony\Component\HttpFoundation\JsonResponse - * The JSON response. - */ - public function json(Request $request) { - // TODO: Client side check if form is valid before sending to server. - $entity = $this->getEntityForRequest($request); - - $preview_data = $this->entityPreviewer->createEntityPreview($entity); - - // The current value of the alias field, if any, - // takes precedence over the entity url. - if (!empty($form_data['path'][0]['alias'])) { - $preview_data['url'] = $form_data['path'][0]['alias']; - } - - return new JsonResponse($preview_data); - } - - /** - * Returns an instantiated preview entity for the request. - * - * TODO: Implement per-request caching and terminate early so this isn't run - * twice for access checks and the controller. - * - * @param \Symfony\Component\HttpFoundation\Request $request - * The request that contains the POST body for this preview. - * - * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException - * If the request contains now post data at all. - * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException - * If the 'yoast_seo_preview' object is omitted from the post data. - * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException - * If the 'action', 'method', and 'path' entries are not in the - * 'yoast_seo_preview' object. - * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException - * If the 'form_id' key is not set in the post data. - * - * @return \Drupal\Core\Entity\Entity - * The instantiated entity. - */ - protected function getEntityForRequest(Request $request) { - // Fetch all our post data. - $content = $request->request->all(); - if (empty($content)) { - throw new BadRequestHttpException("Missing post data"); - } - - // The context for our form is stored under the yoast_seo_preview key. - if (empty($content['yoast_seo_preview'])) { - throw new BadRequestHttpException("Missing preview context"); - } - - $preview_context = $content['yoast_seo_preview']; - unset($content['yoast_seo_preview']); - - // Check if any form content was sent along with our context. - if (empty($content)) { - throw new BadRequestHttpException("Missing preview entity data"); - } - - // Check if we have all the context we require to recreate the form request. - if (empty($preview_context['path']) || - empty($preview_context['action']) || - empty($preview_context['method'])) { - throw new BadRequestHttpException("Missing preview context"); - } - - $form_data = $content; - - // Check if we know which form we are using for the analysis. - if (empty($form_data['form_id'])) { - throw new BadRequestHttpException("Missing form_id in preview entity data"); - } - - return $this->entityPreviewer->entityFromFormSubmission($preview_context['action'], $preview_context['method'], $form_data); - } - -} diff --git a/src/EntityPreviewer.php b/src/EntityAnalyser.php similarity index 99% rename from src/EntityPreviewer.php rename to src/EntityAnalyser.php index 133e3cd..07917de 100644 --- a/src/EntityPreviewer.php +++ b/src/EntityAnalyser.php @@ -21,7 +21,7 @@ use Symfony\Component\Routing\RouterInterface; * * @package Drupal\yoast_seo */ -class EntityPreviewer { +class EntityAnalyser { protected $entityTypeManager; protected $renderer; @@ -258,4 +258,5 @@ class EntityPreviewer { 'description' => 'meta', ]; } -} \ No newline at end of file + +} diff --git a/src/Form/AnalysisFormHandler.php b/src/Form/AnalysisFormHandler.php new file mode 100644 index 0000000..725102f --- /dev/null +++ b/src/Form/AnalysisFormHandler.php @@ -0,0 +1,105 @@ +entityAnalyser = $entity_analyser; + } + + /** + * {@inheritdoc} + */ + public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) { + return new static( + $container->get('yoast_seo.entity_analyser') + ); + } + + /** + * Ajax Callback for returning node preview to seo library. + * + * @param array $form + * The complete form array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * The ajax response. + */ + public function analysisSubmitAjax(array &$form, FormStateInterface $form_state) { + $preview_entity = $form_state->getFormObject()->buildEntity($form, $form_state); + $preview_entity->in_preview = TRUE; + + $entity_data = $this->entityAnalyser->createEntityPreview($preview_entity); + + // The current value of the alias field, if any, + // takes precedence over the entity url. + $user_input = $form_state->getUserInput(); + if (!empty($user_input['path'][0]['alias'])) { + $entity_data['url'] = $user_input['path'][0]['alias']; + } + + // Any form errors were displayed when our form with the analysis was + // rendered. Any new messages are from form validation. We don't want to + // leak those to the user because they'll get them during normal submission + // so we clear them here. + drupal_get_messages(); + + $response = new AjaxResponse(); + $response->addCommand(new InvokeCommand('body', 'trigger', ['updateSeoData', $entity_data])); + return $response; + } + + /** + * Adds yoast_seo_preview submit. + * + * @param array $element + * The form element to process. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + public function addAnalysisSubmit(array &$element, FormStateInterface $form_state) { + $element['yoast_seo_preview_button'] = [ + '#type' => 'button', + '#value' => t('Seo preview'), + '#attributes' => [ + 'class' => ['yoast-seo-preview-submit-button'], + // Inline styles are bad but we can't reliably use class order here. + 'style' => 'display: none', + ], + '#ajax' => [ + 'callback' => [$this, 'analysisSubmitAjax'], + ], + ]; + } + +} diff --git a/src/Form/ConfigForm.php b/src/Form/ConfigForm.php index 96c8953..575183a 100644 --- a/src/Form/ConfigForm.php +++ b/src/Form/ConfigForm.php @@ -4,7 +4,6 @@ namespace Drupal\yoast_seo\Form; use Drupal\Core\Form\FormBase; use Drupal\Core\Form\FormStateInterface; -use Drupal\yoast_seo\FieldManager; use Drupal\yoast_seo\SeoManager; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -22,19 +21,11 @@ class ConfigForm extends FormBase { */ protected $seoManager; - /** - * The Field Manager service. - * - * @var \Drupal\yoast_seo\FieldManager - */ - protected $fieldManager; - /** * {@inheritdoc} */ - public function __construct(SeoManager $seoManager, FieldManager $fieldManager) { + public function __construct(SeoManager $seoManager) { $this->seoManager = $seoManager; - $this->fieldManager = $fieldManager; } /** @@ -42,8 +33,7 @@ class ConfigForm extends FormBase { */ public static function create(ContainerInterface $container) { return new static( - $container->get('yoast_seo.manager'), - $container->get('yoast_seo.field_manager') + $container->get('yoast_seo.manager') ); } @@ -107,13 +97,13 @@ class ConfigForm extends FormBase { // If it's checked now but wasn't enabled, enable it. if ($values[$entity_type_id][$bundle_id] !== 0 - && !$this->fieldManager->isEnabledFor($entity_type_id, $bundle_id)) { - $this->fieldManager->attachSeoFields($entity_type_id, $bundle_id); + && !$this->seoManager->isEnabledFor($entity_type_id, $bundle_id)) { + $this->seoManager->enableFor($entity_type_id, $bundle_id); } // If it's not checked but it was enabled, disable it. elseif ($values[$entity_type_id][$bundle_id] === 0 - && $this->fieldManager->isEnabledFor($entity_type_id, $bundle_id)) { - $this->fieldManager->detachSeoFields($entity_type_id, $bundle_id); + && $this->seoManager->isEnabledFor($entity_type_id, $bundle_id)) { + $this->seoManager->disableFor($entity_type_id, $bundle_id); } } } diff --git a/src/Plugin/Field/FieldWidget/YoastSeoWidget.php b/src/Plugin/Field/FieldWidget/YoastSeoWidget.php index 3166b6a..ecaf49a 100644 --- a/src/Plugin/Field/FieldWidget/YoastSeoWidget.php +++ b/src/Plugin/Field/FieldWidget/YoastSeoWidget.php @@ -3,15 +3,15 @@ namespace Drupal\yoast_seo\Plugin\Field\FieldWidget; use Drupal\Component\Utility\Html; -use Drupal\Core\Entity\EntityFieldManagerInterface; use Drupal\Core\Entity\EntityForm; +use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\WidgetBase; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; -use Drupal\Core\Url; use Drupal\yoast_seo\SeoManager; +use Drupal\yoast_seo\Form\AnalysisFormHandler; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -28,11 +28,11 @@ use Symfony\Component\DependencyInjection\ContainerInterface; class YoastSeoWidget extends WidgetBase implements ContainerFactoryPluginInterface { /** - * The entity field manager. + * The entity type manager. * - * @var \Drupal\Core\Entity\EntityFieldManagerInterface + * @var \Drupal\Core\Entity\EntityTypeManagerInterface */ - protected $entityFieldManager; + protected $entityTypeManager; /** * Instance of YoastSeoManager service. @@ -63,7 +63,7 @@ class YoastSeoWidget extends WidgetBase implements ContainerFactoryPluginInterfa $configuration['field_definition'], $configuration['settings'], $configuration['third_party_settings'], - $container->get('entity_field.manager'), + $container->get('entity_type.manager'), $container->get('yoast_seo.manager') ); } @@ -71,8 +71,9 @@ class YoastSeoWidget extends WidgetBase implements ContainerFactoryPluginInterfa /** * {@inheritdoc} */ - public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, EntityFieldManagerInterface $entity_field_manager, SeoManager $manager) { + public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, EntityTypeManagerInterface $entity_type_manager, SeoManager $manager) { parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings); + $this->entityTypeManager = $entity_type_manager; $this->yoastSeoManager = $manager; } @@ -143,6 +144,16 @@ class YoastSeoWidget extends WidgetBase implements ContainerFactoryPluginInterfa $element['#attached']['drupalSettings']['yoast_seo'] = $js_config; + // Add analysis submit button. + $target_type = $this->fieldDefinition->getTargetEntityTypeId(); + if ($this->entityTypeManager->hasHandler($target_type, 'yoast_seo_preview_form')) { + $form_handler = $this->entityTypeManager->getHandler($target_type, 'yoast_seo_preview_form'); + + if ($form_handler instanceof AnalysisFormHandler) { + $form_handler->addAnalysisSubmit($element['yoast_seo'], $form_state); + } + } + return $element; } @@ -178,8 +189,6 @@ class YoastSeoWidget extends WidgetBase implements ContainerFactoryPluginInterfa 'base_root' => $base_root, // Set up score to indiciator word rules. 'score_status' => $score_to_status_rules, - // Set up our analysis endpoint. - 'analysis_endpoint' => Url::fromRoute('yoast_seo.entity_preview')->toString(), ]; // Set up the names of the text outputs. diff --git a/src/SeoManager.php b/src/SeoManager.php index e9a2a2d..5228996 100644 --- a/src/SeoManager.php +++ b/src/SeoManager.php @@ -3,6 +3,7 @@ namespace Drupal\yoast_seo; use Drupal\Core\Entity\EntityTypeBundleInfoInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; use Symfony\Component\Yaml\Yaml; /** @@ -20,23 +21,33 @@ class SeoManager { protected $fieldManager; /** - * Entity Type Manager service. + * Entity Type Bundle Info service. * * @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface */ protected $entityTypeBundleInfo; + /** + * Entity Type Manager service. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + /** * Constructor for YoastSeoManager. * * @param \Drupal\yoast_seo\FieldManager $fieldManager * Real Time SEO Field Manager service. * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entityTypeBundleInfo + * Entity Type Bundle Info service. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager * Entity Type Manager service. */ - public function __construct(FieldManager $fieldManager, EntityTypeBundleInfoInterface $entityTypeBundleInfo) { + public function __construct(FieldManager $fieldManager, EntityTypeBundleInfoInterface $entityTypeBundleInfo, EntityTypeManagerInterface $entityTypeManager) { $this->fieldManager = $fieldManager; $this->entityTypeBundleInfo = $entityTypeBundleInfo; + $this->entityTypeManager = $entityTypeManager; } /** @@ -110,7 +121,7 @@ class SeoManager { foreach ($entities as $entity_type => &$bundles) { foreach ($bundles as $bundle_id => $bundle_label) { - if (!$this->fieldManager->isEnabledFor($entity_type, $bundle_id)) { + if (!$this->isEnabledFor($entity_type, $bundle_id)) { unset($bundles[$bundle_id]); } } @@ -123,6 +134,45 @@ class SeoManager { return $entities; } + /** + * Check whether this module is enabled for a certain entity/bundle. + * + * @param string $entity_type_id + * The entity to check. + * @param string $bundle + * The bundle of the entity to check. + * + * @return bool + * Whether SEO analysis is enabled. + */ + public function isEnabledFor($entity_type_id, $bundle) { + return $this->fieldManager->isEnabledFor($entity_type_id, $bundle); + } + + /** + * Enable this module for a certain entity/bundle. + * + * @param string $entity_type_id + * The entity to enable. + * @param string $bundle + * The bundle of the entity to enable. + */ + public function enableFor($entity_type_id, $bundle) { + $this->fieldManager->attachSeoFields($entity_type_id, $bundle); + } + + /** + * Disable this module for a certain entity/bundle. + * + * @param string $entity_type_id + * The entity to disable. + * @param string $bundle + * The bundle of the entity to disable. + */ + public function disableFor($entity_type_id, $bundle) { + $this->fieldManager->detachSeoFields($entity_type_id, $bundle); + } + /** * Get the status for a given score. * diff --git a/tests/src/Functional/ConfigurationPageTest.php b/tests/src/Functional/ConfigurationPageTest.php index 1b26725..fd6fdbc 100644 --- a/tests/src/Functional/ConfigurationPageTest.php +++ b/tests/src/Functional/ConfigurationPageTest.php @@ -94,7 +94,7 @@ class ConfigurationPageTest extends BrowserTestBase { $this->assertTrue($checked, "Expected Real-Time SEO module to be enabled for 'Article'"); // Check that the SEO analyzer shows up on the article add page. - $this->drupalGet('node/add/article'); + $this->drupalGet('/node/add/article'); $this->assertSession()->pageTextContains('Real-time SEO for drupal'); } diff --git a/yoast_seo.module b/yoast_seo.module index 32f4980..cb6ba87 100644 --- a/yoast_seo.module +++ b/yoast_seo.module @@ -8,6 +8,7 @@ use Drupal\Core\Entity\Display\EntityViewDisplayInterface; use Drupal\Core\Access\AccessResult; use Drupal\Core\Form\FormStateInterface; +use Drupal\yoast_seo\Form\AnalysisFormHandler; /** * Implements hook_form_FORM_ID_alter(). @@ -75,6 +76,30 @@ function yoast_seo_theme() { return $theme; } +/** + * Implements hook_entity_type_build(). + * + * Sets the default yoast_seo_form form handler to enabled entity types. + * + * @see \Drupal\Core\Entity\Annotation\EntityType + */ +function yoast_seo_entity_type_build(array &$entity_types) { + /* @var \Drupal\yoast_seo\SeoManager $seo_manager */ + $seo_manager = \Drupal::service('yoast_seo.manager'); + + // Set the handler for all supported types. + $supported_types = $seo_manager->getSupportedEntityTypes(); + + /* @var $entity_types \Drupal\Core\Entity\EntityTypeInterface[] */ + foreach ($entity_types as &$entity_type) { + if (isset($supported_types[$entity_type->id()])) { + if (!$entity_type->hasHandlerClass('yoast_seo_preview_form')) { + $entity_type->setHandlerClass('yoast_seo_preview_form', AnalysisFormHandler::class); + } + } + } +} + /** * Alter the settings used for displaying an entity. * diff --git a/yoast_seo.routing.yml b/yoast_seo.routing.yml index f1b867c..374ad99 100644 --- a/yoast_seo.routing.yml +++ b/yoast_seo.routing.yml @@ -5,13 +5,3 @@ yoast_seo.settings: _controller: '\Drupal\yoast_seo\Controller\SettingsController::index' requirements: _permission: 'administer yoast seo' - -yoast_seo.entity_preview: - path: '/yoast_seo/preview' - defaults: - _title: 'Entity Preview' - _controller: '\Drupal\yoast_seo\Controller\EntityPreviewController::json' - methods: ['POST'] -# TODO: Implement proper access checking here based on the entity - requirements: - _custom_access: '\Drupal\yoast_seo\Controller\EntityPreviewController::access' \ No newline at end of file diff --git a/yoast_seo.services.yml b/yoast_seo.services.yml index 5254ea8..aef9efb 100644 --- a/yoast_seo.services.yml +++ b/yoast_seo.services.yml @@ -1,10 +1,10 @@ services: yoast_seo.manager: class: Drupal\yoast_seo\SeoManager - arguments: ['@yoast_seo.field_manager', '@entity_type.bundle.info'] + arguments: ['@yoast_seo.field_manager', '@entity_type.bundle.info', '@entity_type.manager'] yoast_seo.field_manager: class: Drupal\yoast_seo\FieldManager arguments: [] - yoast_seo.entity_previewer: - class: Drupal\yoast_seo\EntityPreviewer - arguments: ['@entity_type.manager', '@renderer', '@metatag.manager', '@router.no_access_checks'] \ No newline at end of file + yoast_seo.entity_analyser: + class: Drupal\yoast_seo\EntityAnalyser + arguments: ['@entity_type.manager', '@renderer', '@metatag.manager', '@router.no_access_checks']