Commit 14fb6907 authored by borisson_'s avatar borisson_ Committed by Nick_vh

Issue #2826449 by piyuesh23, borisson_, RumyanaRuseva, gaydabura, andypost,...

Issue #2826449 by piyuesh23, borisson_, RumyanaRuseva, gaydabura, andypost, niknak, tomasbarej, MDJ Webdiensten, recrit, vasike, acbramley, cato, flocondetoile, YurkinPark, StryKaizer, nikunjkotecha, diegodalr3, finnsky, acrollet, meanderix, uzlov, denis_kv, abhishek-anand, sylus, Nick_vh: Ajax Facets blocks for views
parent 21904f05
......@@ -7,6 +7,7 @@
use Drupal\facets\Entity\Facet;
use Drupal\facets\Entity\FacetSource;
use Drupal\block\Entity\Block;
/**
* Convert facets on Search Api facet sources to use the display plugin.
......@@ -149,3 +150,20 @@ function facets_update_8005() {
}
}
}
/**
* Update facet blocks configuration with a block id used for AJAX support.
*/
function facets_update_8006() {
$query = \Drupal::entityQuery('block')
->condition('plugin', 'facet_block', 'STARTS_WITH')
->execute();
foreach ($query as $block_id) {
$block = Block::load($block_id);
$configuration = $block->get('settings');
$configuration['block_id'] = $block_id;
$block->set('settings', $configuration);
$block->save();
}
}
......@@ -53,3 +53,12 @@ soft-limit:
- core/jquery.once
- core/drupal
- core/drupalSettings
drupal.facets.views-ajax:
js:
js/facets-views-ajax.js: {}
dependencies:
- core/jquery
- core/jquery.once
- core/drupal
- core/drupalSettings
- core/drupal.ajax
......@@ -48,3 +48,10 @@ entity.facets_facet_source.edit_form:
_title: 'Edit facet source configuration'
requirements:
_entity_create_access: 'facets_facet'
facets.block.ajax:
path: '/facets-block-ajax'
defaults:
_controller: '\Drupal\facets\Controller\FacetBlockAjaxController::ajaxFacetBlockView'
requirements:
_access: 'TRUE'
......@@ -43,7 +43,7 @@
checkbox.on('change.facets', function (e) {
Drupal.facets.disableFacet($link.parents('.js-facets-checkbox-links'));
window.location.href = $(this).data('facetsredir');
$(this).siblings('a')[0].click();
});
if (active) {
......@@ -51,7 +51,7 @@
label.find('.js-facet-deactivate').remove();
}
$link.before(checkbox).before(label).remove();
$link.before(checkbox).before(label).hide();
};
......
......@@ -38,6 +38,7 @@
$dropdown.removeClass('js-facets-dropdown-links');
$dropdown.addClass('facets-dropdown');
$dropdown.addClass('js-facets-dropdown');
var id = $(this).data('drupal-facet-id');
var default_option_label = settings.facets.dropdown_widget[id]['facet-default-option-label'];
......@@ -68,7 +69,8 @@
// Go to the selected option when it's clicked.
$dropdown.on('change.facets', function () {
window.location.href = $(this).val();
var a = $($ul).find("[data-drupal-facet-item-id='" + $(this).find(':selected').data('drupalFacetItemId') + "']");
$(a)[0].click();
});
// Append empty text option.
......@@ -77,7 +79,7 @@
}
// Replace links with dropdown.
$ul.after($dropdown).remove();
$ul.after($dropdown).hide();
Drupal.attachBehaviors($dropdown.parent()[0], Drupal.settings);
});
};
......
/**
* @file
* Facets views AJAX handling.
*/
(function ($, Drupal) {
'use strict';
/**
* Keep the original beforeSend method to use it later.
*/
var beforeSend = Drupal.Ajax.prototype.beforeSend;
/**
* Trigger views AJAX refresh on click.
*/
Drupal.behaviors.facetsViewsAjax = {
attach: function (context, settings) {
// Loop through all facets.
$.each(settings.facets_views_ajax, function (facetId, facetSettings) {
// Get the View for the current facet.
var view, current_dom_id, view_path;
if (settings.views && settings.views.ajaxViews) {
$.each(settings.views.ajaxViews, function (domId, viewSettings) {
// Check if we have facet for this view.
if (facetSettings.view_id == viewSettings.view_name && facetSettings.current_display_id == viewSettings.view_display_id) {
view = $('.js-view-dom-id-' + viewSettings.view_dom_id);
current_dom_id = viewSettings.view_dom_id;
view_path = facetSettings.ajax_path;
}
});
}
if (!view || view.length != 1) {
return;
}
// Update view on summary block click.
if (updateFacetsSummaryBlock() && (facetId === 'facets_summary_ajax')) {
$('[data-drupal-facets-summary-id=' + facetSettings.facets_summary_id + ']').children('ul').children('li').once().click(function (e) {
e.preventDefault();
var facetLink = $(this).find('a');
updateFacetsView(facetLink.attr('href'), current_dom_id, view_path);
});
}
// Update view on facet item click.
else {
$('[data-drupal-facet-id=' + facetId + ']').find('.facet-item').once().each(function (index, facet_item) {
$(facet_item).children('a').once().click(function (e) {
e.preventDefault();
updateFacetsView($(this).attr('href'), current_dom_id, view_path);
});
});
$('[data-drupal-facet-id=' + facetId + ']').each(function (index, facet_item) {
if ($(facet_item).hasClass('js-facets-dropdown')) {
$(facet_item).unbind('change.facets');
$(facet_item).on('change.facets', function () {
updateFacetsView($(this).val(), current_dom_id, view_path);
});
}
});
}
});
}
};
// Helper function to update views output & Ajax facets.
var updateFacetsView = function (href, current_dom_id, view_path) {
// Refresh view.
var views_parameters = Drupal.Views.parseQueryString(href);
var views_arguments = Drupal.Views.parseViewArgs(href, 'search');
var views_settings = $.extend(
{},
Drupal.views.instances['views_dom_id:' + current_dom_id].settings,
views_arguments,
views_parameters
);
// Update View.
var views_ajax_settings = Drupal.views.instances['views_dom_id:' + current_dom_id].element_settings;
views_ajax_settings.submit = views_settings;
views_ajax_settings.url = view_path + '?q=' + href;
Drupal.ajax(views_ajax_settings).execute();
// Update url.
window.historyInitiated = true;
window.history.pushState(null, document.title, href);
// ToDo: Update views+facets with ajax on history back.
// For now we will reload the full page.
window.addEventListener("popstate", function (e) {
if (window.historyInitiated) {
window.location.reload();
}
});
// Refresh facets blocks.
updateFacetsBlocks(href);
}
// Helper function, updates facet blocks.
var updateFacetsBlocks = function (href) {
var settings = drupalSettings;
var facets_blocks = facetsBlocks();
// Update facet blocks.
let facet_settings = {
url: Drupal.url('facets-block-ajax'),
submit: {
facet_link: href,
facets_blocks: facets_blocks
}
};
// Update facets summary block.
if (updateFacetsSummaryBlock()) {
var facet_summary_wrapper_id = $('[data-drupal-facets-summary-id=' + settings.facets_views_ajax.facets_summary_ajax.facets_summary_id + ']').attr('id');
var facet_summary_block_id = '';
if (facet_summary_wrapper_id.indexOf('--') !== -1) {
facet_summary_block_id = facet_summary_wrapper_id.substring(0, facet_summary_wrapper_id.indexOf('--')).replace('block-', '');
}
else {
facet_summary_block_id = facet_summary_wrapper_id.replace('block-', '');
}
facet_settings.submit.update_summary_block = true;
facet_settings.submit.facet_summary_block_id = facet_summary_block_id;
facet_settings.submit.facet_summary_wrapper_id = settings.facets_views_ajax.facets_summary_ajax.facets_summary_id;
}
Drupal.ajax(facet_settings).execute();
};
// Helper function to determine if we should update the summary block.
// Returns true or false.
var updateFacetsSummaryBlock = function () {
var settings = drupalSettings;
var update_summary = false;
if (settings.facets_views_ajax.facets_summary_ajax) {
update_summary = true;
}
return update_summary;
};
// Helper function, return facet blocks.
var facetsBlocks = function () {
// Get all ajax facets blocks from the current page.
var facets_blocks = {};
$('.block-facets-ajax').each(function (index) {
var block_id_start = 'js-facet-block-id-';
var block_id = $.map($(this).attr('class').split(' '), function (v, i) {
if (v.indexOf(block_id_start) > -1) {
return v.slice(block_id_start.length, v.length);
}
}).join();
var block_selector = '#' + $(this).attr('id');
facets_blocks[block_id] = block_selector;
});
return facets_blocks;
};
/**
* Overrides beforeSend to trigger facetblocks update on exposed filter change.
*
* @param {XMLHttpRequest} xmlhttprequest
* Native Ajax object.
* @param {object} options
* jQuery.ajax options.
*/
Drupal.Ajax.prototype.beforeSend = function (xmlhttprequest, options) {
// Update facet blocks as well.
// Get view from options.
if (typeof options.extraData !== 'undefined' && typeof options.extraData.view_name !== 'undefined') {
var href = window.location.href;
var settings = drupalSettings;
// TODO: Maybe we should limit facet block reloads by view?
var reload = false;
$.each(settings.facets_views_ajax, function (facetId, facetSettings) {
if (facetSettings.view_id == options.extraData.view_name && facetSettings.current_display_id == options.extraData.view_display_id) {
reload = true;
}
});
if (reload) {
updateFacetsBlocks(href);
}
}
// Call the original Drupal method with the right context.
beforeSend.apply(this, arguments);
}
})(jQuery, Drupal);
......@@ -5,6 +5,7 @@ namespace Drupal\facets_summary\Plugin\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Cache\UncacheableDependencyTrait;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Url;
use Drupal\facets_summary\Entity\FacetsSummary;
use Drupal\facets_summary\FacetsSummaryBlockInterface;
use Drupal\facets_summary\FacetsSummaryManager\DefaultFacetsSummaryManager;
......@@ -96,6 +97,18 @@ class FacetsSummaryBlock extends BlockBase implements FacetsSummaryBlockInterfac
];
}
/** @var \Drupal\views\ViewExecutable $view */
$view = $facets_summary->getFacetSource()->getViewsDisplay();
$build['#attached']['drupalSettings']['facets_views_ajax'] = [
'facets_summary_ajax' => [
'facets_summary_id' => $facets_summary->id(),
'view_id' => $view->id(),
'current_display_id' => $view->current_display,
'ajax_path' => Url::fromRoute('views.ajax')->toString(),
],
];
return $build;
}
......
<?php
namespace Drupal\facets\Controller;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\InvokeCommand;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Path\CurrentPathStack;
use Drupal\Core\PathProcessor\PathProcessorManager;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Routing\CurrentRouteMatch;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\RouterInterface;
/**
* Defines a controller to load a facet via AJAX.
*/
class FacetBlockAjaxController extends ControllerBase {
/**
* The entity storage for block.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $storage;
/**
* The renderer.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* The current path.
*
* @var \Drupal\Core\Path\CurrentPathStack
*/
protected $currentPath;
/**
* The dynamic router service.
*
* @var \Symfony\Component\Routing\Matcher\RequestMatcherInterface
*/
protected $router;
/**
* The path processor service.
*
* @var \Drupal\Core\PathProcessor\InboundPathProcessorInterface
*/
protected $pathProcessor;
/**
* The current route match service.
*
* @var \Drupal\Core\Routing\CurrentRouteMatch
*/
protected $currentRouteMatch;
/**
* Constructs a FacetBlockAjaxController object.
*
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer service.
* @param \Drupal\Core\Path\CurrentPathStack $currentPath
* The current path service.
* @param \Symfony\Component\Routing\RouterInterface $router
* The router service.
* @param \Drupal\Core\PathProcessor\PathProcessorManager $pathProcessor
* The path processor manager.
* @param \Drupal\Core\Routing\CurrentRouteMatch $currentRouteMatch
* The current route match service.
*/
public function __construct(RendererInterface $renderer, CurrentPathStack $currentPath, RouterInterface $router, PathProcessorManager $pathProcessor, CurrentRouteMatch $currentRouteMatch) {
$this->storage = $this->entityTypeManager()->getStorage('block');
$this->renderer = $renderer;
$this->currentPath = $currentPath;
$this->router = $router;
$this->pathProcessor = $pathProcessor;
$this->currentRouteMatch = $currentRouteMatch;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('renderer'),
$container->get('path.current'),
$container->get('router'),
$container->get('path_processor_manager'),
$container->get('current_route_match')
);
}
/**
* Loads and renders the facet blocks via AJAX.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The current request object.
*
* @return \Drupal\Core\Ajax\AjaxResponse
* The ajax response.
*
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
* Thrown when the view was not found.
*/
public function ajaxFacetBlockView(Request $request) {
$response = new AjaxResponse();
// Rebuild the request and the current path, needed for facets.
$path = $request->request->get('facet_link');
$facets_blocks = $request->request->get('facets_blocks');
// Make sure we are not updating blocks multiple times.
$facets_blocks = array_unique($facets_blocks);
if (empty($path) || empty($facets_blocks)) {
throw new NotFoundHttpException('No facet link or facet blocks found.');
}
$this->currentRouteMatch->resetRouteMatch();
$new_request = Request::create($path);
$request_stack = new RequestStack();
$processed = $this->pathProcessor->processInbound($path, $new_request);
$this->currentPath->setPath($processed, $new_request);
$request->attributes->add($this->router->matchRequest($new_request));
$request_stack->push($new_request);
$container = \Drupal::getContainer();
$container->set('request_stack', $request_stack);
$active_facet = $request->request->get('active_facet');
// Build the facets blocks found for the current request and update.
foreach ($facets_blocks as $block_id => $block_selector) {
$block_entity = $this->storage->load($block_id);
if ($block_entity) {
// Render a block, then add it to the response as a replace command.
$block_view = $this->entityTypeManager
->getViewBuilder('block')
->view($block_entity);
$block_view = (string) $this->renderer->renderPlain($block_view);
$response->addCommand(new ReplaceCommand($block_selector, $block_view));
}
}
$response->addCommand(new InvokeCommand('[data-block-plugin-id="' . $active_facet . '"]', 'addClass', ['facet-active']));
// Update filter summary block.
$update_summary_block = $request->request->get('update_summary_block');
if ($update_summary_block) {
$facet_summary_block_id = $request->request->get('facet_summary_block_id');
$facet_summary_wrapper_id = $request->request->get('facet_summary_wrapper_id');
$facet_summary_block_id = str_replace('-', '_', $facet_summary_block_id);
if ($facet_summary_block_id) {
$block_entity = $this->storage->load($facet_summary_block_id);
$block_view = $this->entityTypeManager
->getViewBuilder('block')
->view($block_entity);
$block_view = (string) $this->renderer->renderPlain($block_view);
$response->addCommand(new ReplaceCommand('[data-drupal-facets-summary-id=' . $facet_summary_wrapper_id . ']', $block_view));
}
}
return $response;
}
}
......@@ -334,7 +334,19 @@ class DefaultFacetManager {
];
}
else {
return [];
// If the facet has no results, but it is being rendered trough ajax we
// should render a container (that is empty). This is because the
// javascript needs to be able to find a div to replace with the new
// content.
return [
[
'#type' => 'container',
'#attributes' => [
'data-drupal-facet-id' => $facet->id(),
'class' => 'facet-empty',
],
],
];
}
}
......
......@@ -116,6 +116,13 @@ abstract class FacetSourcePluginBase extends PluginBase implements FacetSourcePl
return $this->keys;
}
/**
* {@inheritdoc}
*/
public function buildFacet() {
return [];
}
/**
* {@inheritdoc}
*/
......
......@@ -105,4 +105,14 @@ interface FacetSourcePluginInterface extends PluginFormInterface, DependentPlugi
*/
public function getDataDefinition($field_name);
/**
* Builds and returns an extra renderable array for this facet block plugin.
*
* @return array
* A renderable array representing the content of the block.
*
* @see \Drupal\facets\Plugin\facets\facet_source\SearchApiDisplay
*/
public function buildFacet();
}
......@@ -5,7 +5,7 @@ namespace Drupal\facets\Plugin\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Plugin\PluginBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\facets\FacetManager\DefaultFacetManager;
use Symfony\Component\DependencyInjection\ContainerInterface;
......@@ -82,11 +82,29 @@ class FacetBlock extends BlockBase implements ContainerFactoryPluginInterface {
// Let the facet_manager build the facets.
$build = $this->facetManager->build($facet);
// Add contextual links only when we have results.
if (!empty($build)) {
// Add extra elements from facet source, for example, ajax scripts.
// @see Drupal\facets\Plugin\facets\facet_source\SearchApiDisplay
/* @var \Drupal\facets\FacetSource\FacetSourcePluginInterface $facet_source */
$facet_source = $facet->getFacetSource();
$build += $facet_source->buildFacet();
// Add contextual links only when we have results.
$build['#contextual_links']['facets_facet'] = [
'route_parameters' => ['facets_facet' => $facet->id()],
];
// Add classes needed for ajax.
if (!empty($build['#use_ajax'])) {
$build['#attributes']['class'][] = 'block-facets-ajax';
// The configuration block id isn't always set in the configuration.
if (isset($this->configuration['block_id'])) {
$build['#attributes']['class'][] = 'js-facet-block-id-' . $this->configuration['block_id'];
}
else {
$build['#attributes']['class'][] = 'js-facet-block-id-' . $this->pluginId;
}
}
}
return $build;
......@@ -135,4 +153,14 @@ class FacetBlock extends BlockBase implements ContainerFactoryPluginInterface {
return ['config' => [$facet->getConfigDependencyName()]];
}
/**
* {@inheritdoc}
*/
public function blockSubmit($form, FormStateInterface $form_state) {
// Save block id to configuration, we do this for loading the original block
// with ajax.
$block_id = $form['id']['#value'];
$this->configuration['block_id'] = $block_id;
}
}
......@@ -5,6 +5,7 @@ namespace Drupal\facets\Plugin\facets\facet_source;
use Drupal\Component\Plugin\DependentPluginInterface;
use Drupal\Core\Extension\ModuleHandler;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
use Drupal\facets\Exception\Exception;
use Drupal\facets\Exception\InvalidQueryTypeException;
use Drupal\facets\FacetInterface;
......@@ -372,4 +373,31 @@ class SearchApiDisplay extends FacetSourcePluginBase implements SearchApiFacetSo
throw new Exception("Field with name {$field_name} does not have a definition");
}
/**
* {@inheritdoc}
*/
public function buildFacet() {
$build = parent::buildFacet();
$view = $this->getViewsDisplay();
if ($view === NULL) {
return $build;
}
// Add JS for Views with Ajax Enabled.
if ($view->display_handler->ajaxEnabled()) {
$js_settings = [
'view_id' => $view->id(),
'current_display_id' => $view->current_display,
'view_base_path' => ltrim($view->getPath(), '/'),
'ajax_path' => Url::fromRoute('views.ajax')->toString(),
];
$build['#attached']['library'][] = 'facets/drupal.facets.views-ajax';
$build['#attached']['drupalSettings']['facets_views_ajax'] = [
$this->facet->id() => $js_settings,
];
$build['#use_ajax'] = TRUE;
}
return $build;
}
}
......@@ -6,6 +6,7 @@ use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Link;
use Drupal\Core\Plugin\PluginBase;
use Drupal\Core\Url;
use Drupal\facets\FacetInterface;
use Drupal\facets\Result\Result;
use Drupal\facets\Result\ResultInterface;
......@@ -48,6 +49,21 @@ abstract class WidgetPluginBase extends PluginBase implements WidgetPluginInterf
return $this->buildResultItem($result);
}
else {
// When the facet is being build in an AJAX request, and the facetsource
// is a block, we need to update the url to use the current request url.
if ($result->getUrl()->isRouted() && $result->getUrl()->getRouteName() === 'facets.block.ajax') {
$request = \Drupal::request();
$url_object = \Drupal::service('path.validator')
->getUrlIfValid($request->getPathInfo());
if ($url_object) {
$url = $result->getUrl();
$options = $url->getOptions();
$route_params = $url_object->getRouteParameters();
$route_name = $url_object->getRouteName();
$result->setUrl(new Url($route_name, $route_params, $options));
}
}
return $this->buildListItems($facet, $result);
}
}, $facet->getResults());
......
......@@ -795,7 +795,7 @@ class ProcessorIntegrationTest extends FacetsTestBase {
$this->drupalPostForm($this->editForm, $form, 'Save');
$this->drupalGet('search-api-test-fulltext');
$this->assertSession()->pageTextContains(' Displaying 1 search results');
$this->assertSession()->pageTextContains('Displaying 1 search results');
$this->assertNoFacetBlocksAppear();
}
......
......@@ -63,8 +63,13 @@ trait TestHelperTrait {
*/
protected function assertNoFacetBlocksAppear() {
foreach ($this->blocks as $block) {
$this->assertFalse($this->xpath('//div[@id = :id]', [':id' => 'block-' . $block->id()]));
$this->assertSession()->pageTextNotContains($block->label());
$xpath = $this->xpath('//div[@id = :id]/div[@class="facet-empty"]', [':id' => 'block-' . $block->id()]);
if (!$xpath) {
$this->assertFalse($xpath);
}
else {
$this->assertTrue($this->xpath('//div[@id = :id]/div[@class="facet-empty"]', [':id' => 'block-' . $block->id()]));
}
}
}
......
<?php
namespace Drupal\Tests\facets\FunctionalJavascript;
use Drupal\views\Entity\View;
/**
* Tests for the JS that powers ajax.