Commit dc0cb544 authored by Kingdutch's avatar Kingdutch

Issue #2573899 by volkerk, Kingdutch: Paragraphs module support

This commit implements an alternative analysis method where an
entire entity is rendered. This makes the module agnostic of field
types for its analysis purposes. By hooking into the entity form
process different entity types can be supported and no entity
specific handlers will need to be written.

This also fixes #2917280
parent 4275843c
......@@ -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();
};
/**
......
<?php
namespace Drupal\yoast_seo\Controller;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Session\AccountInterface;
use Drupal\yoast_seo\EntityPreviewer;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Provides methods for interacting with entity previews.
*/
class EntityPreviewController extends ControllerBase {
/**
* Used to create the entity needed for analysis.
*
* @var \Drupal\yoast_seo\EntityPreviewer
*/
protected $entityPreviewer;
/**
* Method used to set-up this class with depedency injection.
*
* @param \Symfony\Component\DependencyInjection\ContainerInterface $container
* The service container.
*
* @return static
*/
public static function create(ContainerInterface $container) {
return new static(
$container->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);
}
}
......@@ -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
}
<?php
namespace Drupal\yoast_seo\Form;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\InvokeCommand;
use Drupal\Core\DependencyInjection\DependencySerializationTrait;
use Drupal\Core\Entity\EntityHandlerInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\yoast_seo\EntityAnalyser;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Base class for yoast_seo_preview form handlers.
*/
class AnalysisFormHandler implements EntityHandlerInterface {
use DependencySerializationTrait;
/**
* The entity analyser.
*
* @var \Drupal\yoast_seo\EntityAnalyser
*/
protected $entityAnalyser;
/**
* SeoPreviewFormHandler constructor.
*
* @param \Drupal\yoast_seo\EntityAnalyser $entity_analyser
* The entity analyser.
*/
public function __construct(EntityAnalyser $entity_analyser) {
$this->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'],
],
];
}
}
......@@ -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);
}
}
}
......
......@@ -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.
......
......@@ -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.
*
......
......@@ -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');
}
......
......@@ -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;
}