diff --git a/config/vufind/RecordTabs.ini b/config/vufind/RecordTabs.ini index 12918bcd5ab..294de25cfe1 100644 --- a/config/vufind/RecordTabs.ini +++ b/config/vufind/RecordTabs.ini @@ -64,6 +64,7 @@ tabs[HierarchyTree] = HierarchyTree tabs[Map] = Map tabs[Versions] = Versions tabs[Similar] = SimilarItemsCarousel +;tabs[Channels] = Channels tabs[Details] = StaffViewArray defaultTab = null ;backgroundLoadedTabs[] = UserComments @@ -86,6 +87,7 @@ tabs[HierarchyTree] = HierarchyTree tabs[Map] = Map tabs[Versions] = Versions tabs[Similar] = SimilarItemsCarousel +;tabs[Channels] = Channels tabs[Details] = StaffViewArray defaultTab = null ;backgroundLoadedTabs[] = UserComments @@ -106,6 +108,7 @@ tabs[HierarchyTree] = HierarchyTree tabs[Map] = Map tabs[Versions] = Versions tabs[Similar] = SimilarItemsCarousel +;tabs[Channels] = Channels tabs[Details] = StaffViewMARC defaultTab = null @@ -120,6 +123,7 @@ tabs[Preview] = preview tabs[HierarchyTree] = HierarchyTree tabs[Versions] = Versions tabs[Similar] = SimilarItemsCarousel +;tabs[Channels] = Channels tabs[Details] = StaffViewOverdrive defaultTab = null @@ -130,6 +134,7 @@ tabs[UserComments] = UserComments tabs[Reviews] = Reviews tabs[Excerpt] = Excerpt tabs[Preview] = preview +;tabs[Channels] = Channels tabs[Details] = StaffViewArray defaultTab = null diff --git a/config/vufind/channels.ini b/config/vufind/channels.ini index 93edc5b7295..e72feaef10b 100644 --- a/config/vufind/channels.ini +++ b/config/vufind/channels.ini @@ -15,6 +15,11 @@ default_home_source = "Solr" ; Should we cache channel results on the Channels/Home screen? cache_home_channels = true +; This section controls behavior of the Channels record tab. +[RecordTab] +; What text should display in the tab itself? +label = "Channels" + ; This section controls which providers are used for Solr searches/records. ; Providers may be followed by a colon and the name of a configuration section ; to use. If no configuration section is provided, the default of @@ -64,6 +69,11 @@ home[] = "facets:provider.facets.home" record[] = "similaritems" record[] = "facets" ;record[] = "alphabrowse" +; Providers to use in the Channels tab on the record page when it is enabled. +; The code will fall back to record settings above if recordTab is empty/omitted. +;recordTab[] = "similaritems" +;recordTab[] = "facets" +;recordTab[] = "alphabrowse" ; Providers to use for search-based channels (order matters!) search[] = "facets" search[] = "similaritems" diff --git a/languages/en.ini b/languages/en.ini index c12a6300db0..cec2f62eb46 100644 --- a/languages/en.ini +++ b/languages/en.ini @@ -224,6 +224,7 @@ channel_expand = "Explore related channels" channel_explore = "Explore Channels" channel_search = "Show items as search results" channel_searchbox_label = "Search for more channels:" +Channels = "Channels" Check Hold = "Check Hold" Check Recall = "Check Recall" check_profile = "Check user information." diff --git a/module/VuFind/src/VuFind/ChannelProvider/ChannelLoader.php b/module/VuFind/src/VuFind/ChannelProvider/ChannelLoader.php index 954c1ab6117..5179fa253cf 100644 --- a/module/VuFind/src/VuFind/ChannelProvider/ChannelLoader.php +++ b/module/VuFind/src/VuFind/ChannelProvider/ChannelLoader.php @@ -49,72 +49,24 @@ */ class ChannelLoader { - /** - * Cache manager - * - * @var CacheManager - */ - protected $cacheManager; - - /** - * Channel manager - * - * @var ChannelManager - */ - protected $channelManager; - - /** - * Channel configuration - * - * @var Config - */ - protected $config; - - /** - * Record loader - * - * @var RecordLoader - */ - protected $recordLoader; - - /** - * Search runner - * - * @var SearchRunner - */ - protected $searchRunner; - - /** - * Current locale (used for caching) - * - * @var string - */ - protected $locale; - /** * Constructor * - * @param Config $config Channels configuration - * @param CacheManager $cache Cache manager - * @param ChannelManager $cm Channel manager - * @param SearchRunner $runner Search runner - * @param RecordLoader $loader Record loader - * @param string $locale Current locale (used for caching) + * @param Config $config Channels configuration + * @param CacheManager $cacheManager Cache manager + * @param ChannelManager $channelManager Channel manager + * @param SearchRunner $searchRunner Search runner + * @param RecordLoader $recordLoader Record loader + * @param string $locale Current locale (used for caching) */ public function __construct( - Config $config, - CacheManager $cache, - ChannelManager $cm, - SearchRunner $runner, - RecordLoader $loader, - string $locale = '' + protected Config $config, + protected CacheManager $cacheManager, + protected ChannelManager $channelManager, + protected SearchRunner $searchRunner, + protected RecordLoader $recordLoader, + protected string $locale = '' ) { - $this->config = $config; - $this->cacheManager = $cache; - $this->channelManager = $cm; - $this->searchRunner = $runner; - $this->recordLoader = $loader; - $this->locale = $locale; } /** @@ -165,7 +117,7 @@ protected function getChannelsFromResults($providers, Results $results, $token) * if the channelProvider GET parameter is set). * * @param string $source Search backend ID - * @param array $configSection Configuration section to load ID list from + * @param string $configSection Configuration section to load ID list from * @param string $activeId Currently selected channel ID (if any; used * when making an AJAX request for a single additional channel) * @@ -262,10 +214,11 @@ public function getHomeContext( /** * Generates channels for a record. * - * @param string $recordId Record ID to load - * @param string $token Channel token (optional, used for AJAX fetching) - * @param string $activeChannel Channel being requested (optional, used w/ token) - * @param string $source Search backend to use + * @param string $recordId Record ID to load + * @param string $token Channel token (optional, used for AJAX fetching) + * @param string $activeChannel Channel being requested (optional, used w/ token) + * @param string $source Search backend to use + * @param array $configSections Prioritized list of configuration sections to check * * @return array */ @@ -273,13 +226,20 @@ public function getRecordContext( $recordId, $token = null, $activeChannel = null, - $source = DEFAULT_SEARCH_BACKEND + $source = DEFAULT_SEARCH_BACKEND, + array $configSections = ['record'] ) { // Load record: $driver = $this->recordLoader->load($recordId, $source); // Load appropriate channel objects: - $providers = $this->getChannelProviders($source, 'record', $activeChannel); + $providers = []; + foreach ($configSections as $section) { + $providers = $this->getChannelProviders($source, $section, $activeChannel); + if (!empty($providers)) { + break; + } + } // Collect details: $channels = []; diff --git a/module/VuFind/src/VuFind/RecordTab/Channels.php b/module/VuFind/src/VuFind/RecordTab/Channels.php new file mode 100644 index 00000000000..f4798366341 --- /dev/null +++ b/module/VuFind/src/VuFind/RecordTab/Channels.php @@ -0,0 +1,102 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:record_tabs Wiki + */ + +namespace VuFind\RecordTab; + +use VuFind\ChannelProvider\ChannelLoader; + +/** + * Channels tab + * + * @category VuFind + * @package RecordTabs + * @author Demian Katz + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:record_tabs Wiki + */ +class Channels extends AbstractBase +{ + /** + * Config sections in channels.ini to use for loading channel settings. + * + * @var array + */ + protected array $configSections = ['recordTab', 'record']; + + /** + * Constructor + * + * @param ChannelLoader $loader Channel loader + * @param array $options Config settings + */ + public function __construct(protected ChannelLoader $loader, protected array $options = []) + { + } + + /** + * Get the on-screen description for this tab. + * + * @return string + */ + public function getDescription() + { + return $this->options['label'] ?? 'Channels'; + } + + /** + * Can this tab be loaded via AJAX? + * + * @return bool + */ + public function supportsAjax() + { + // Due to heavy Javascript in channels, the tab cannot be AJAX-loaded: + return false; + } + + /** + * Return context variables used for rendering the block's template. + * + * @return array + */ + public function getContext() + { + $request = $this->getRequest() ?: null; + $query = $request?->getQuery(); + $driver = $this->getRecordDriver(); + $context = ['displaySearchBox' => false]; + return $context + $this->loader->getRecordContext( + $driver->getUniqueID(), + $query?->get('channelToken'), + $query?->get('channelProvider'), + $driver->getSearchBackendIdentifier(), + $this->configSections + ); + } +} diff --git a/module/VuFind/src/VuFind/RecordTab/ChannelsFactory.php b/module/VuFind/src/VuFind/RecordTab/ChannelsFactory.php new file mode 100644 index 00000000000..025a4e4d904 --- /dev/null +++ b/module/VuFind/src/VuFind/RecordTab/ChannelsFactory.php @@ -0,0 +1,76 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ + +namespace VuFind\RecordTab; + +use Laminas\ServiceManager\Exception\ServiceNotCreatedException; +use Laminas\ServiceManager\Exception\ServiceNotFoundException; +use Psr\Container\ContainerExceptionInterface as ContainerException; +use Psr\Container\ContainerInterface; +use VuFind\ChannelProvider\ChannelLoader; + +/** + * Factory for building the Channels tab. + * + * @category VuFind + * @package RecordTabs + * @author Demian Katz + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +class ChannelsFactory implements \Laminas\ServiceManager\Factory\FactoryInterface +{ + /** + * Create an object + * + * @param ContainerInterface $container Service manager + * @param string $requestedName Service being created + * @param null|array $options Extra options (optional) + * + * @return object + * + * @throws ServiceNotFoundException if unable to resolve the service. + * @throws ServiceNotCreatedException if an exception is raised when + * creating a service. + * @throws ContainerException&\Throwable if any other error occurs + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke( + ContainerInterface $container, + $requestedName, + ?array $options = null + ) { + if (!empty($options)) { + throw new \Exception('Unexpected options passed to factory.'); + } + $config = $container->get(\VuFind\Config\PluginManager::class)->get('channels')->toArray(); + return new $requestedName($container->get(ChannelLoader::class), $config['RecordTab'] ?? []); + } +} diff --git a/module/VuFind/src/VuFind/RecordTab/PluginManager.php b/module/VuFind/src/VuFind/RecordTab/PluginManager.php index 6bb011e1b2c..481c87bc90a 100644 --- a/module/VuFind/src/VuFind/RecordTab/PluginManager.php +++ b/module/VuFind/src/VuFind/RecordTab/PluginManager.php @@ -48,6 +48,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager * @var array */ protected $aliases = [ + 'channels' => Channels::class, 'collectionhierarchytree' => CollectionHierarchyTree::class, 'collectionlist' => CollectionList::class, 'componentparts' => ComponentParts::class, @@ -76,6 +77,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager * @var array */ protected $factories = [ + Channels::class => ChannelsFactory::class, CollectionHierarchyTree::class => CollectionHierarchyTreeFactory::class, CollectionList::class => CollectionListFactory::class, ComponentParts::class => ComponentPartsFactory::class, diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/ChannelProvider/ChannelLoaderTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/ChannelProvider/ChannelLoaderTest.php new file mode 100644 index 00000000000..bd30d55972c --- /dev/null +++ b/module/VuFind/tests/unit-tests/src/VuFindTest/ChannelProvider/ChannelLoaderTest.php @@ -0,0 +1,225 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ + +namespace VuFindTest\ChannelProvider; + +use PHPUnit\Framework\MockObject\MockObject; +use VuFind\Cache\Manager as CacheManager; +use VuFind\ChannelProvider\AbstractChannelProvider; +use VuFind\ChannelProvider\ChannelLoader; +use VuFind\ChannelProvider\PluginManager; +use VuFind\Config\Config; +use VuFind\Record\Loader as RecordLoader; +use VuFind\RecordDriver\DefaultRecord; +use VuFind\Search\Base\Results; +use VuFind\Search\SearchRunner; + +/** + * ChannelLoader Test Class + * + * @category VuFind + * @package Tests + * @author Sudharma Kellampalli + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ +class ChannelLoaderTest extends \PHPUnit\Framework\TestCase +{ + /** + * Data provider for testGetRecordContext() + * + * @return array[] + */ + public static function getRecordContextProvider(): array + { + return [ + 'no configuration' => [[], [], ['record']], + 'one provider' => [ + [ + 'source.Solr' => [ + 'record' => ['bar'], + ], + ], + ['bar'], + ['record'], + ], + 'two providers, including config' => [ + [ + 'source.Solr' => [ + 'record' => ['bar', 'baz:xyzzy'], + ], + 'xyzzy' => [ + 'extraConfig', + ], + ], + ['bar', 'baz-extraConfig'], + ['record'], + ], + 'override section' => [ + [ + 'source.Solr' => [ + 'record' => ['bar'], + 'recordTab' => ['override'], + ], + ], + ['override'], + ['recordTab', 'record'], + ], + 'proper section fallback' => [ + [ + 'source.Solr' => [ + 'record' => ['bar'], + ], + ], + ['bar'], + ['recordTab', 'record'], + ], + ]; + } + + /** + * Test getRecordContext + * + * @param array $config Configuration + * @param array $expectedChannelData The channel data we expect to retrieve + * @param array $sections Config sections to look at for provider settings + * + * @return void + * + * @dataProvider getRecordContextProvider + */ + public function testGetRecordContext(array $config, array $expectedChannelData, array $sections): void + { + $mockRecord = $this->createMock(DefaultRecord::class); + $recordLoader = $this->getMockRecordLoader(); + $recordLoader->expects($this->once())->method('load')->with('foo', 'Solr')->willReturn($mockRecord); + $loader = $this->getChannelLoader($config, $recordLoader); + $context = $loader->getRecordContext('foo', configSections: $sections); + $this->assertEquals(['driver', 'channels', 'token'], array_keys($context)); + $this->assertEquals($mockRecord, $context['driver']); + $this->assertEquals($expectedChannelData, $context['channels']); + $this->assertNull($context['token']); + } + + /** + * Get a mock record loader. + * + * @return MockObject&RecordLoader + */ + protected function getMockRecordLoader(): MockObject&RecordLoader + { + return $this->createMock(RecordLoader::class); + } + + /** + * Get a mock plugin manager that creates fake providers that can be used for testing behavior. + * + * @return MockObject&PluginManager + */ + protected function getMockPluginManager(): MockObject&PluginManager + { + $manager = $this->createMock(PluginManager::class); + $manager->method('get')->willReturnCallback( + function ($settings) { + return new class ($settings) extends AbstractChannelProvider { + /** + * Constructor + * + * @param string $settings Initial settings to save + */ + public function __construct(protected string $settings) + { + } + + /** + * Set the options for the provider. + * + * @param array $options Options + * + * @return void + */ + public function setOptions(array $options) + { + if (!empty($options)) { + $this->settings .= '-' . implode(':', $options); + } + } + + /** + * Return channel information derived from a record driver object. + * + * @param RecordDriver $driver Record driver + * @param string $channelToken Token identifying a single specific channel + * to load (if omitted, all channels will be loaded) + * + * @return array + */ + public function getFromRecord(\VuFind\RecordDriver\AbstractBase $driver, $channelToken = null) + { + return [$this->settings]; + } + + /** + * Return channel information derived from a search results object. + * + * @param Results $results Search results + * @param string $channelToken Token identifying a single specific channel + * to load (if omitted, all channels will be loaded) + * + * @return array + */ + public function getFromSearch(Results $results, $channelToken = null) + { + return [$this->settings]; + } + }; + } + ); + return $manager; + } + + /** + * Get a channel loader to test. + * + * @param array $config Configuration + * @param ?RecordLoader $recordLoader Record loader (null to create default mock) + * + * @return ChannelLoader + */ + protected function getChannelLoader(array $config = [], ?RecordLoader $recordLoader = null): ChannelLoader + { + return new ChannelLoader( + new Config($config), + $this->createMock(CacheManager::class), + $this->getMockPluginManager(), + $this->createMock(SearchRunner::class), + $recordLoader ?? $this->getMockRecordLoader() + ); + } +} diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/RecordTab/ChannelsTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/RecordTab/ChannelsTest.php new file mode 100644 index 00000000000..dbb8dca6c56 --- /dev/null +++ b/module/VuFind/tests/unit-tests/src/VuFindTest/RecordTab/ChannelsTest.php @@ -0,0 +1,131 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ + +namespace VuFindTest\RecordTab; + +use Laminas\Http\Request; +use Laminas\Stdlib\Parameters; +use VuFind\ChannelProvider\ChannelLoader; +use VuFind\RecordDriver\AbstractBase as RecordDriver; +use VuFind\RecordTab\Channels; + +/** + * Channels Test Class + * + * @category VuFind + * @package Tests + * @author Demian Katz + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ +class ChannelsTest extends \PHPUnit\Framework\TestCase +{ + /** + * Get the object to test. + * + * @param array $options Config options + * @param ?ChannelLoader $mockLoader Channel loader (null for default mock) + * @param ?RecordDriver $mockDriver Record driver (null for default mock) + * + * @return Channels + */ + protected function getChannels( + array $options = [], + ?ChannelLoader $mockLoader = null, + ?RecordDriver $mockDriver = null + ): Channels { + $channels = new Channels($mockLoader ?? $this->createMock(ChannelLoader::class), $options); + $channels->setRecordDriver($mockDriver ?? $this->createMock(RecordDriver::class)); + return $channels; + } + + /** + * Test getDescription(). + * + * @return void + */ + public function testGetDescription(): void + { + // Default case: + $this->assertEquals('Channels', $this->getChannels()->getDescription()); + // Custom case: + $this->assertEquals('Custom', $this->getChannels(['label' => 'Custom'])->getDescription()); + } + + /** + * Test supportsAjax(). + * + * @return void + */ + public function testSupportsAjax(): void + { + $this->assertFalse($this->getChannels()->supportsAjax()); + } + + /** + * Test getContext() with default config and no request set. + * + * @return void + */ + public function testGetContextWithoutRequest(): void + { + $driver = $this->createMock(RecordDriver::class); + $driver->method('getUniqueID')->willReturn('foo'); + $driver->method('getSearchBackendIdentifier')->willReturn('bar'); + $loader = $this->createMock(ChannelLoader::class); + $loader->expects($this->once()) + ->method('getRecordContext') + ->with('foo', null, null, 'bar', ['recordTab', 'record']) + ->willReturn(['record' => 'context']); + $channels = $this->getChannels([], $loader, $driver); + $this->assertEquals(['displaySearchBox' => false, 'record' => 'context'], $channels->getContext()); + } + + /** + * Test getContext() with default config and a request set. + * + * @return void + */ + public function testGetContextWithRequest(): void + { + $driver = $this->createMock(RecordDriver::class); + $driver->method('getUniqueID')->willReturn('foo'); + $driver->method('getSearchBackendIdentifier')->willReturn('bar'); + $loader = $this->createMock(ChannelLoader::class); + $loader->expects($this->once()) + ->method('getRecordContext') + ->with('foo', 'tok', 'prov', 'bar', ['recordTab', 'record']) + ->willReturn(['record' => 'context']); + $channels = $this->getChannels([], $loader, $driver); + $request = new Request(); + $request->setQuery(new Parameters(['channelToken' => 'tok', 'channelProvider' => 'prov'])); + $channels->setRequest($request); + $this->assertEquals(['displaySearchBox' => false, 'record' => 'context'], $channels->getContext()); + } +} diff --git a/themes/bootstrap3/templates/RecordTab/channels.phtml b/themes/bootstrap3/templates/RecordTab/channels.phtml new file mode 100644 index 00000000000..36f23f47e17 --- /dev/null +++ b/themes/bootstrap3/templates/RecordTab/channels.phtml @@ -0,0 +1 @@ +render('channels/channelList.phtml', $this->tab->getContext()); diff --git a/themes/bootstrap3/templates/channels/channelList.phtml b/themes/bootstrap3/templates/channels/channelList.phtml index 4f7cc2653ab..3ee440ae42a 100644 --- a/themes/bootstrap3/templates/channels/channelList.phtml +++ b/themes/bootstrap3/templates/channels/channelList.phtml @@ -23,7 +23,7 @@ } ?> - +