diff --git a/search_api.module b/search_api.module index 4caef001..ccbd9990 100644 --- a/search_api.module +++ b/search_api.module @@ -8,6 +8,7 @@ use Drupal\comment\Entity\Comment; use Drupal\Core\Config\ConfigImporter; use Drupal\Core\Entity\ContentEntityInterface; +use Drupal\Core\Entity\ContentEntityType; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Url; @@ -25,6 +26,7 @@ use Drupal\search_api\Task\IndexTaskManager; use Drupal\views\ViewEntityInterface; use Drupal\views\ViewExecutable; +use Drupal\Core\Entity\Display\EntityViewDisplayInterface; /** * Implements hook_help(). @@ -731,3 +733,44 @@ function search_api_form_views_exposed_form_alter(&$form, FormStateInterface $fo } } } + +/** + * Implements hook_entity_extra_field_info(). + */ +function search_api_entity_extra_field_info() { + $extra = []; + + // Add an extra "excerpt" field to every content entity. + $entity_types = \Drupal::entityTypeManager()->getDefinitions(); + $bundle_info = \Drupal::getContainer()->get('entity_type.bundle.info'); + foreach ($entity_types as $entity_type_id => $entity_type) { + if ($entity_type instanceof ContentEntityType) { + $bundles = $bundle_info->getBundleInfo($entity_type_id); + foreach ($bundles as $bundle => $data) { + $extra[$entity_type_id][$bundle]['display']['search_api_excerpt'] = [ + 'label' => t('Search result excerpt'), + 'description' => t('An excerpt provided by Search API when rendered in a search.'), + 'weight' => 100, + 'visible' => FALSE, + ]; + } + } + } + return $extra; +} + +/** + * Implements hook_entity_view(). + */ +function search_api_entity_view(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) { + $excerpt_component = $display->getComponent('search_api_excerpt'); + if ($excerpt_component !== NULL && isset($build['#search_api_excerpt'])) { + $build['search_api_excerpt'] = [ + '#type' => 'markup', + '#markup' => $build['#search_api_excerpt'], + '#cache' => [ + 'contexts' => ['url.query_args'] + ], + ]; + } +} diff --git a/src/Plugin/search_api/processor/RenderedItem.php b/src/Plugin/search_api/processor/RenderedItem.php index 1f003888..e03978e8 100644 --- a/src/Plugin/search_api/processor/RenderedItem.php +++ b/src/Plugin/search_api/processor/RenderedItem.php @@ -269,6 +269,10 @@ public function addFieldValues(ItemInterface $item) { try { $build = $datasource->viewItem($item->getOriginalObject(), $view_mode); + // Add the excerpt to the render array to allow adding it to view modes. + if ($item->getExcerpt()) { + $build['#search_api_excerpt'] = $item->getExcerpt(); + } $value = (string) $this->getRenderer()->renderPlain($build); if ($value) { $field->addValue($value); diff --git a/src/Plugin/views/row/SearchApiRow.php b/src/Plugin/views/row/SearchApiRow.php index 42e72159..d583e632 100644 --- a/src/Plugin/views/row/SearchApiRow.php +++ b/src/Plugin/views/row/SearchApiRow.php @@ -198,7 +198,14 @@ public function render($row) { } try { - return $this->index->getDatasource($datasource_id)->viewItem($row->_object, $view_mode); + $build = $this->index->getDatasource($datasource_id) + ->viewItem($row->_object, $view_mode); + // Add the excerpt to the render array to allow adding it to view modes. + if (isset($row->search_api_excerpt)) { + $build['#search_api_excerpt'] = $row->search_api_excerpt; + } + + return $build; } catch (SearchApiException $e) { $this->logException($e); diff --git a/tests/search_api_test_excerpt_field/config/install/core.entity_view_display.entity_test_mulrev_changed.article.search_result.yml b/tests/search_api_test_excerpt_field/config/install/core.entity_view_display.entity_test_mulrev_changed.article.search_result.yml new file mode 100644 index 00000000..691f2bf8 --- /dev/null +++ b/tests/search_api_test_excerpt_field/config/install/core.entity_view_display.entity_test_mulrev_changed.article.search_result.yml @@ -0,0 +1,15 @@ +langcode: en +status: true +dependencies: + config: + - core.entity_view_mode.entity_test_mulrev_changed.search_result +id: entity_test_mulrev_changed.article.search_result +targetEntityType: entity_test_mulrev_changed +bundle: article +mode: search_result +content: + search_api_excerpt: + weight: 0 + region: content + settings: { } + third_party_settings: { } diff --git a/tests/search_api_test_excerpt_field/config/install/core.entity_view_display.entity_test_mulrev_changed.item.search_result.yml b/tests/search_api_test_excerpt_field/config/install/core.entity_view_display.entity_test_mulrev_changed.item.search_result.yml new file mode 100644 index 00000000..8b2eaecc --- /dev/null +++ b/tests/search_api_test_excerpt_field/config/install/core.entity_view_display.entity_test_mulrev_changed.item.search_result.yml @@ -0,0 +1,15 @@ +langcode: en +status: true +dependencies: + config: + - core.entity_view_mode.entity_test_mulrev_changed.search_result +id: entity_test_mulrev_changed.item.search_result +targetEntityType: entity_test_mulrev_changed +bundle: item +mode: search_result +content: + search_api_excerpt: + weight: 0 + region: content + settings: { } + third_party_settings: { } diff --git a/tests/search_api_test_excerpt_field/config/install/core.entity_view_mode.entity_test_mulrev_changed.search_result.yml b/tests/search_api_test_excerpt_field/config/install/core.entity_view_mode.entity_test_mulrev_changed.search_result.yml new file mode 100644 index 00000000..2a2d0a3b --- /dev/null +++ b/tests/search_api_test_excerpt_field/config/install/core.entity_view_mode.entity_test_mulrev_changed.search_result.yml @@ -0,0 +1,9 @@ +langcode: en +status: true +dependencies: + module: + - entity_test +id: entity_test_mulrev_changed.search_result +label: 'Search result' +targetEntityType: entity_test_mulrev_changed +cache: true diff --git a/tests/search_api_test_excerpt_field/config/install/views.view.search_api_test_excerpt_field.yml b/tests/search_api_test_excerpt_field/config/install/views.view.search_api_test_excerpt_field.yml new file mode 100644 index 00000000..b19e1871 --- /dev/null +++ b/tests/search_api_test_excerpt_field/config/install/views.view.search_api_test_excerpt_field.yml @@ -0,0 +1,119 @@ +langcode: en +status: true +dependencies: + config: + - search_api.index.database_search_index + module: + - search_api +id: search_api_test_excerpt_field +label: 'Search API Test excerpt field' +module: views +description: '' +tag: '' +base_table: search_api_index_database_search_index +base_field: search_api_id +core: 8.x +display: + default: + display_plugin: default + id: default + display_title: Master + position: 0 + display_options: + access: + type: none + options: { } + cache: + type: none + options: { } + query: + type: search_api_query + options: + bypass_access: true + skip_access: true + exposed_form: + type: basic + options: + submit_button: Apply + reset_button: false + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: true + sort_asc_label: Asc + sort_desc_label: Desc + pager: + type: mini + options: + items_per_page: 10 + offset: 0 + id: 0 + total_pages: null + expose: + items_per_page: false + items_per_page_label: 'Items per page' + items_per_page_options: '5, 10, 25, 50' + items_per_page_options_all: false + items_per_page_options_all_label: '- All -' + offset: false + offset_label: Offset + tags: + previous: ‹‹ + next: ›› + style: + type: default + options: + row_class: '' + default_row_class: true + uses_fields: false + row: + type: search_api + options: + view_modes: + 'entity:entity_test_mulrev_changed': + article: search_result + item: search_result + fields: { } + filters: { } + sorts: { } + title: 'Search API Test search view caching' + header: + result: + id: result + table: views + field: result + relationship: none + group_type: group + admin_label: '' + empty: true + content: 'Displaying @total search results FOO BAR BAZ' + plugin_id: result + footer: { } + empty: { } + relationships: { } + arguments: { } + display_extenders: { } + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url + - url.query_args + tags: { } + page: + display_plugin: page + id: page + display_title: Disabled + position: 1 + display_options: + display_extenders: { } + path: search-api-test-excerpt-field + display_description: '' + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url + - url.query_args + tags: { } diff --git a/tests/search_api_test_excerpt_field/search_api_test_excerpt_field.info.yml b/tests/search_api_test_excerpt_field/search_api_test_excerpt_field.info.yml new file mode 100644 index 00000000..7c3cf4dd --- /dev/null +++ b/tests/search_api_test_excerpt_field/search_api_test_excerpt_field.info.yml @@ -0,0 +1,10 @@ +name: 'Search API Excerpt Field Test' +type: module +description: 'Support module for testing the Search API Excerpt field' +package: Testing +dependencies: + - search_api:search_api + - search_api:search_api_test_db + - drupal:views +core: 8.x +hidden: true diff --git a/tests/search_api_test_excerpt_field/search_api_test_excerpt_field.module b/tests/search_api_test_excerpt_field/search_api_test_excerpt_field.module new file mode 100644 index 00000000..1ad14577 --- /dev/null +++ b/tests/search_api_test_excerpt_field/search_api_test_excerpt_field.module @@ -0,0 +1,20 @@ +get('search_api_test_excerpt_field', $excerptTemplate); + foreach ($results->getResultItems() as $itemId => $item) { + $item->setExcerpt(str_replace('{{item_id}}', $itemId, $excerptTemplate)); + } +} diff --git a/tests/src/Functional/ExcerptFieldTest.php b/tests/src/Functional/ExcerptFieldTest.php new file mode 100644 index 00000000..0c3d89fe --- /dev/null +++ b/tests/src/Functional/ExcerptFieldTest.php @@ -0,0 +1,83 @@ +get('search_api.index_task_manager') + ->addItemsAll(Index::load($this->indexId)); + $this->insertExampleContent(); + $this->indexItems($this->indexId); + + // Do not use a batch for tracking the initial items after creating an + // index when running the tests via the GUI. Otherwise, it seems Drupal's + // Batch API gets confused and the test fails. + if (!Utility::isRunningInCli()) { + \Drupal::state()->set('search_api_use_tracking_batch', FALSE); + } + } + + /** + * Tests that the "Search excerpt" field in entity displays works correctly. + */ + public function testSearchExcerptField() { + $assertSession = $this->assertSession(); + + $path = '/search-api-test-excerpt-field'; + $this->drupalGet($path); + foreach ($this->ids as $itemId) { + $assertSession->pageTextContains("Item $itemId test excerpt"); + } + + // Visiting the same page a second time retrieves the rendered node from + // cache, not using the updated test excerpt template. + $stateKey = 'search_api_test_excerpt_field'; + \Drupal::state()->set($stateKey, 'test--{{item_id}}--excerpt'); + $this->drupalGet($path); + foreach ($this->ids as $itemId) { + $assertSession->pageTextContains("Item $itemId test excerpt"); + $assertSession->pageTextNotContains("test--$itemId--excerpt"); + } + + // Changing the GET parameters does skip the render cache for the nodes. + $this->drupalGet($path, ['query' => ['foo' => 'bar']]); + foreach ($this->ids as $itemId) { + $assertSession->pageTextContains("test--$itemId--excerpt"); + $assertSession->pageTextNotContains("Item $itemId test excerpt"); + } + } + +} diff --git a/tests/src/Functional/SearchApiBrowserTestBase.php b/tests/src/Functional/SearchApiBrowserTestBase.php index 125b3018..e0ad8dd9 100644 --- a/tests/src/Functional/SearchApiBrowserTestBase.php +++ b/tests/src/Functional/SearchApiBrowserTestBase.php @@ -7,6 +7,7 @@ use Drupal\search_api\Entity\Server; use Drupal\search_api\Utility\Utility; use Drupal\Tests\BrowserTestBase; +use Symfony\Component\DependencyInjection\ContainerInterface; /** * Provides the base class for web tests for Search API. @@ -24,6 +25,13 @@ abstract class SearchApiBrowserTestBase extends BrowserTestBase { 'search_api_test', ]; + /** + * Set this to TRUE to include "item" and "article" bundles for test entities. + * + * @var bool + */ + protected static $additionalBundles = FALSE; + /** * An admin user used for this test. * @@ -194,4 +202,27 @@ protected function executeTasks() { $this->assertEquals(0, $task_manager->getTasksCount(), 'No more pending tasks.'); } + /** + * {@inheritdoc} + */ + protected function initConfig(ContainerInterface $container) { + parent::initConfig($container); + + if (!static::$additionalBundles) { + return; + } + + // This will just set the Drupal state to include the necessary bundles for + // our test entity type. Otherwise, fields from those bundles won't be found + // and thus removed from the test index. (We can't do it in setUp(), before + // calling the parent method, since the container isn't set up at that + // point.) + $bundles = [ + 'entity_test_mulrev_changed' => ['label' => 'Entity Test Bundle'], + 'item' => ['label' => 'item'], + 'article' => ['label' => 'article'], + ]; + \Drupal::state()->set('entity_test_mulrev_changed.bundles', $bundles); + } + } diff --git a/tests/src/Functional/ViewsTest.php b/tests/src/Functional/ViewsTest.php index 0e5146ef..f17bff6a 100644 --- a/tests/src/Functional/ViewsTest.php +++ b/tests/src/Functional/ViewsTest.php @@ -12,7 +12,6 @@ use Drupal\search_api\Entity\Index; use Drupal\search_api\Utility\Utility; use Drupal\views\Entity\View; -use Symfony\Component\DependencyInjection\ContainerInterface; /** * Tests the Views integration of the Search API. @@ -35,6 +34,11 @@ class ViewsTest extends SearchApiBrowserTestBase { 'views_ui', ]; + /** + * {@inheritdoc} + */ + protected static $additionalBundles = TRUE; + /** * {@inheritdoc} */ @@ -997,25 +1001,10 @@ public function testHighlighting() { $options['query']['search_api_fulltext'] = 'foo'; $this->drupalGet($path, $options); $this->assertSession()->responseContains('foo bar baz'); - } - /** - * {@inheritdoc} - */ - protected function initConfig(ContainerInterface $container) { - parent::initConfig($container); - - // This will just set the Drupal state to include the necessary bundles for - // our test entity type. Otherwise, fields from those bundles won't be found - // and thus removed from the test index. (We can't do it in setUp(), before - // calling the parent method, since the container isn't set up at that - // point.) - $bundles = [ - 'entity_test_mulrev_changed' => ['label' => 'Entity Test Bundle'], - 'item' => ['label' => 'item'], - 'article' => ['label' => 'article'], - ]; - \Drupal::state()->set('entity_test_mulrev_changed.bundles', $bundles); + $options['query']['search_api_fulltext'] = 'bar'; + $this->drupalGet($path, $options); + $this->assertSession()->responseContains('foo bar baz'); } } diff --git a/tests/src/Kernel/Processor/ProcessorTestBase.php b/tests/src/Kernel/Processor/ProcessorTestBase.php index af6a68a5..1a77c0bd 100644 --- a/tests/src/Kernel/Processor/ProcessorTestBase.php +++ b/tests/src/Kernel/Processor/ProcessorTestBase.php @@ -153,29 +153,49 @@ public function setUp($processor = NULL) { * @return \Drupal\search_api\Item\ItemInterface[] * The generated test items. */ - public function generateItems(array $items) { + protected function generateItems(array $items) { /** @var \Drupal\search_api\Item\ItemInterface[] $extracted_items */ $extracted_items = []; - foreach ($items as $item) { - $id = Utility::createCombinedId($item['datasource'], $item['item_id']); - $extracted_items[$id] = \Drupal::getContainer() - ->get('search_api.fields_helper') - ->createItemFromObject($this->index, $item['item'], $id); - foreach ([NULL, $item['datasource']] as $datasource_id) { - foreach ($this->index->getFieldsByDatasource($datasource_id) as $key => $field) { - /** @var \Drupal\search_api\Item\FieldInterface $field */ - $field = clone $field; - if (isset($item[$field->getPropertyPath()])) { - $field->addValue($item[$field->getPropertyPath()]); - } - $extracted_items[$id]->setField($key, $field); - } - } + foreach ($items as $values) { + $item = $this->generateItem($values); + $extracted_items[$item->getId()] = $item; } return $extracted_items; } + /** + * Generates a single test item. + * + * @param array $values + * An associative array with the following keys: + * - datasource: The datasource plugin ID. + * - item: The item object to be indexed. + * - item_id: The datasource-specific raw item ID. + * - *: Any other keys will be treated as property paths, and their values + * as a single value for a field with that property path. + * + * @return \Drupal\search_api\Item\Item|\Drupal\search_api\Item\ItemInterface + * The generated test item. + */ + protected function generateItem(array $values) { + $id = Utility::createCombinedId($values['datasource'], $values['item_id']); + $item = \Drupal::getContainer() + ->get('search_api.fields_helper') + ->createItemFromObject($this->index, $values['item'], $id); + foreach ([NULL, $values['datasource']] as $datasource_id) { + foreach ($this->index->getFieldsByDatasource($datasource_id) as $key => $field) { + /** @var \Drupal\search_api\Item\FieldInterface $field */ + $field = clone $field; + if (isset($values[$field->getPropertyPath()])) { + $field->addValue($values[$field->getPropertyPath()]); + } + $item->setField($key, $field); + } + } + return $item; + } + /** * Indexes all (unindexed) items. * diff --git a/tests/src/Kernel/Processor/RenderedItemTest.php b/tests/src/Kernel/Processor/RenderedItemTest.php index c48d2715..9ae867e0 100644 --- a/tests/src/Kernel/Processor/RenderedItemTest.php +++ b/tests/src/Kernel/Processor/RenderedItemTest.php @@ -5,6 +5,7 @@ use Drupal\comment\CommentInterface; use Drupal\comment\Entity\Comment; use Drupal\comment\Entity\CommentType; +use Drupal\Core\Entity\Entity\EntityViewDisplay; use Drupal\Core\Entity\Entity\EntityViewMode; use Drupal\Core\TypedData\DataDefinitionInterface; use Drupal\field\Entity\FieldConfig; @@ -14,6 +15,7 @@ use Drupal\node\Entity\NodeType; use Drupal\node\NodeInterface; use Drupal\search_api\Entity\Index; +use Drupal\search_api\Plugin\search_api\data_type\value\TextValueInterface; use Drupal\search_api\Utility\Utility; use Drupal\user\Entity\User; use Drupal\user\UserInterface; @@ -256,7 +258,6 @@ public function testAddFieldValues() { default: $this->assertTrue(FALSE); } - } } @@ -354,6 +355,40 @@ public function testHideRenderedItem() { } } + /** + * Tests that the "Search excerpt" field in entity displays works correctly. + */ + public function testSearchExcerptField() { + \Drupal::getContainer()->get('module_installer') + ->install(['search_api_test_excerpt_field']); + $this->installEntitySchema('entity_view_mode'); + + $view_mode = EntityViewDisplay::load('node.article.teaser'); + $view_mode->set('content', [ + 'search_api_excerpt' => [ + 'weight' => 0, + 'region' => 'content', + ], + ]); + $view_mode->save(); + + $item = $this->generateItem([ + 'datasource' => 'entity:node', + 'item' => $this->nodes[3]->getTypedData(), + 'item_id' => 3, + ]); + $test_value = 'This is the test excerpt value'; + $item->setExcerpt($test_value); + + $this->processor->addFieldValues($item); + $rendered_item = $item->getField('rendered_item'); + + $values = $rendered_item->getValues(); + $this->assertCount(1, $values); + $this->assertInstanceOf(TextValueInterface::class, $values[0]); + $this->assertContains($test_value, (string) $values[0]); + } + /** * Tests whether the property is correctly added by the processor. */