Skip to content
Snippets Groups Projects
Commit bcd067a0 authored by Sean Dietrich's avatar Sean Dietrich
Browse files

Reworked project to work with pagination and caching

parent e267c453
No related branches found
No related tags found
No related merge requests found
Pipeline #293577 passed with warnings
......@@ -3,10 +3,13 @@
namespace Drupal\api_browser\Entity;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Config\Entity\ConfigEntityBase;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\project_browser\Plugin\ProjectBrowserSourceManager;
use Drupal\Core\Transliteration\PhpTransliteration;
use Drupal\project_browser\ProjectBrowser\Project;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
......@@ -80,18 +83,25 @@ class ApiBrowserService extends ConfigEntityBase {
protected ?LoggerInterface $logger = NULL;
/**
* Remove project browser source from config and clear the cached definitions.
* Cache Backend Service.
*
* @var \Drupal\Core\Cache\CacheBackendInterface|null
*/
protected function removeProjectBrowserSource(): void {
$config = \Drupal::configFactory()->getEditable('project_browser.admin_settings');
$sources = $config->get('enabled_sources');
if ($index = array_search('api_browser_project:' . $this->id(), $sources)) {
unset($sources[$index]);
$config->set('enabled_sources', $sources);
$config->save();
}
\Drupal::service(ProjectBrowserSourceManager::class)->clearCachedDefinitions();
}
protected ?CacheBackendInterface $cacheBackend = NULL;
/**
* Transliteration Service.
*
* @var \Drupal\Core\Transliteration\PhpTransliteration|null
*/
protected ?PhpTransliteration $transliteration = NULL;
/**
* Renderer Service.
*
* @var \Drupal\Core\Render\RendererInterface|null
*/
protected ?RendererInterface $renderer = NULL;
/**
* Return the logging interface.
......@@ -158,6 +168,13 @@ class ApiBrowserService extends ConfigEntityBase {
return $this->get('listing')['path'];
}
/**
* Return if pagination is enabled for listing endpoint.
*/
public function getListingPaginationEnabled(): bool {
return $this->get('listing')['pagination'] ?? FALSE;
}
/**
* Return the listing page variable.
*/
......@@ -284,6 +301,44 @@ class ApiBrowserService extends ConfigEntityBase {
* Return the array of the projects.
*/
protected function getProjectList(array $query = []): array {
$cid = $this->id() . ':results';
$cache = $this->getCacheBackend()->get($cid);
if ($cache) {
$results = $cache->data;
}
else {
$results = $this->queryListingEndpoint($query);
$this->getCacheBackend()->set(
$cid,
$results,
Cache::PERMANENT,
[
'api_browser:results',
'api_browser:results:' . $this->id(),
]
);
}
$list = [];
foreach ($results as $key => $item) {
$item['item_key'] = $key;
if ($project = $this->getProject($item)) {
$list[] = $project;
}
}
return $list;
}
/**
* Query the listing endpoint to return results.
*
* @param array $query
* Array of data passed in by Project Browser query.
*
* @return array
* Data returned from the listing endpoint.
*/
protected function queryListingEndpoint(array $query = []): array {
$client = $this->getClient();
$variables = [];
......@@ -293,6 +348,7 @@ class ApiBrowserService extends ConfigEntityBase {
$authType = $this->getListingAuthType();
$options = $this->authorizeRequest($authType, $credentials, $options);
if ($this->getListingPaginationEnabled()) {
if (
!empty($pageVariable = $this->getListingPageVariable()) &&
isset($query['page'])
......@@ -306,6 +362,7 @@ class ApiBrowserService extends ConfigEntityBase {
) {
$variables[$perPageVariable] = $query['limit'];
}
}
foreach ($this->get('search') as $searchParam) {
$variables[$searchParam['parameter']] = $searchParam['value'];
......@@ -323,7 +380,6 @@ class ApiBrowserService extends ConfigEntityBase {
break;
}
$list = [];
try {
$endpoint = $this->getListingEndpoint();
$this->moduleHandler()->alter('api_browser_project_list_request', $options, $endpoint);
......@@ -334,21 +390,18 @@ class ApiBrowserService extends ConfigEntityBase {
)->getBody()->getContents();
$items = Json::decode($response);
$this->moduleHandler()->alter('api_browser_project_list', $items);
$results = search($this->getListingResultsPath(), $items);
foreach ($results as $key => $item) {
$item['item_key'] = $key;
if ($project = $this->getProject($item)) {
$list[] = $project;
if (count($results) > $query['limit'] && $this->getListingPaginationEnabled()) {
$query['page'] = $query['page'] + 1;
$results = array_merge($results, $this->queryListingEndpoint($query));
}
}
}
catch (GuzzleException $guzzleException) {
$this->getLogger()->error($guzzleException->getMessage());
catch (GuzzleException | \Throwable $exception) {
$this->getLogger()->error($exception->getMessage());
$results = [];
}
return $list;
return $results;
}
/**
......@@ -361,6 +414,13 @@ class ApiBrowserService extends ConfigEntityBase {
* Return the generated project.
*/
protected function getProject(array $record = []): ?Project {
$project_hash = md5(Json::encode($record));
$cid = $this->id() . ':result:' . $project_hash;
$cache = $this->getCacheBackend()->get($cid);
if ($cache) {
$projectInfo = $cache->data;
}
else {
$client = $this->getClient();
try {
$options = [];
......@@ -415,7 +475,6 @@ class ApiBrowserService extends ConfigEntityBase {
'warnings' => explode(',', $this->renderTwigTemplate($this->getFieldMappingField('warnings'), $result)),
'type' => $this->renderTwigTemplate($this->getFieldMappingField('type'), $result),
];
if (!empty($projectInfo['logo']) && filter_var($projectInfo['logo'], FILTER_VALIDATE_URL)) {
$projectInfo['logo'] = [
'file' => [
......@@ -426,16 +485,30 @@ class ApiBrowserService extends ConfigEntityBase {
];
}
$this->getCacheBackend()->set(
$cid,
$projectInfo,
Cache::PERMANENT,
[
'api_browser:results',
'api_browser:results:' . $this->id(),
'api_browser:project_info',
'api_browser:project_info:' . $this->id(),
'api_browser:project_info:' . $this->id() . ':' . $project_hash,
]
);
}
catch (GuzzleException | \Throwable $exception) {
$this->getLogger()->error($exception->getMessage());
return NULL;
}
}
$project = new Project(
...(array_values($projectInfo))
);
$this->moduleHandler()->alter('api_browser_project_browser_project', $project, $result);
}
catch (GuzzleException $guzzleException) {
$this->getLogger()->error($guzzleException->getMessage());
return NULL;
}
return $project;
}
......@@ -500,15 +573,15 @@ class ApiBrowserService extends ConfigEntityBase {
'#context' => $context,
];
// Render twig template.
$output = \Drupal::service('renderer')->renderInIsolation($build);
$output = $this->getRenderer()->renderInIsolation($build);
// Remove extra spaces to make a single line.
$output = preg_replace('#(\s){2,}#', ' ', $output);
return $output;
}
catch (\Exception $exception) {
$this->getLogger()->error($exception->getMessage());
return '';
$output = '';
}
return $output;
}
/**
......@@ -529,12 +602,49 @@ class ApiBrowserService extends ConfigEntityBase {
* @see \Drupal\system\MachineNameController::transliterate()
*/
protected function getMachineName(string $string): string {
$transliterated = \Drupal::transliteration()->transliterate($string, LanguageInterface::LANGCODE_DEFAULT, '_');
$transliterated = $this->getTransliteration()->transliterate($string, LanguageInterface::LANGCODE_DEFAULT, '_');
$transliterated = mb_strtolower($transliterated);
$transliterated = preg_replace('@[^a-z0-9_.]+@', '_', $transliterated);
return $transliterated;
}
/**
* Return the cache service.
*
* @return \Drupal\Core\Cache\CacheBackendInterface
* Cache backend service.
*/
protected function getCacheBackend(): CacheBackendInterface {
if (!$this->cacheBackend) {
$this->cacheBackend = \Drupal::cache();
}
return $this->cacheBackend;
}
/**
* Return renderer service.
*
* @return \Drupal\Core\Render\RendererInterface
* Renderer service.
*/
protected function getRenderer(): RendererInterface {
if (!$this->renderer) {
$this->renderer = \Drupal::service('renderer');
}
return $this->renderer;
}
/**
* Return transliteration service.
*
* @return \Drupal\Core\Transliteration\PhpTransliteration
* Transliteration service.
*/
protected function getTransliteration(): PhpTransliteration {
if (!$this->transliteration) {
$this->transliteration = \Drupal::transliteration();
}
return $this->transliteration;
}
}
......@@ -2,8 +2,11 @@
namespace Drupal\api_browser\Form;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Entity\EntityForm;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Link;
use Drupal\Core\Url;
use Drupal\project_browser\Plugin\ProjectBrowserSourceManager;
use Symfony\Component\DependencyInjection\ContainerInterface;
......@@ -61,6 +64,13 @@ class ApiBrowserServiceForm extends EntityForm {
'#description' => $this->t('Details about the service and what it does.'),
];
$form['categories'] = [
'#type' => 'textarea',
'#title' => $this->t('Categories'),
'#default_value' => $entity->get('categories') ?? '',
'#description' => $this->t('List of categories to use for the projects. Each category should be on a new line in the format <code>key:label</code>.'),
];
$form['listing'] = [
'#type' => 'details',
'#title' => $this->t('Listing Details'),
......@@ -71,14 +81,14 @@ class ApiBrowserServiceForm extends EntityForm {
'#type' => 'textfield',
'#title' => $this->t('Endpoint'),
'#default_value' => $entity->get('listing')['endpoint'] ?? '',
'#description' => $this->t('Endpoint to return a list of the projects.'),
'#description' => $this->t('URL endpoint to use to get a list of the projects.'),
'#required' => TRUE,
];
$form['listing']['method'] = [
'#type' => 'select',
'#title' => $this->t('Request Method'),
'#default_value' => $entity->get('listing')['method'] ?? '',
'#description' => $this->t('Request method to use for searching.'),
'#description' => $this->t('Request method to use for searching. Currently only GET/POST are supported.'),
'#options' => [
'GET' => $this->t('GET'),
'POST' => $this->t('POST'),
......@@ -89,7 +99,7 @@ class ApiBrowserServiceForm extends EntityForm {
'#type' => 'select',
'#title' => $this->t('Authentication Type'),
'#default_value' => $entity->get('listing')['auth'] ?? '',
'#description' => $this->t('Authentication method to use. Public is only supported at this time.'),
'#description' => $this->t('Authentication method to use for the listing endpoint.'),
'#options' => $this->getAuthTypes(),
'#required' => TRUE,
'#ajax' => [
......@@ -120,14 +130,16 @@ class ApiBrowserServiceForm extends EntityForm {
'#type' => 'textfield',
'#title' => $this->t('Results Path'),
'#default_value' => $entity->get('listing')['path'] ?? '',
'#description' => $this->t('Path to use for looping through the listings. Following should be in JSONPath format.'),
'#description' => $this->t('Path to use for looping through the listings. Following should be in @link format.', [
'@link' => Link::fromTextAndUrl('JMESPath', Url::fromUri('https://jmespath.org/'))->toString(),
]),
'#required' => TRUE,
];
$form['listing']['pagination'] = [
'#type' => 'checkbox',
'#title' => $this->t('Contains Pagination'),
'#description' => $this->t('This endpoint is paginated.'),
'#title' => $this->t('Listing allows for pagination'),
'#description' => $this->t('This endpoint contains a way to page through results.'),
'#default_value' => $entity->get('listing')['pagination'] ?? FALSE,
];
......@@ -144,7 +156,7 @@ class ApiBrowserServiceForm extends EntityForm {
'#type' => 'textfield',
'#title' => $this->t('Variable to use for pagination'),
'#default_value' => $entity->get('listing')['page'] ?? '',
'#description' => $this->t('Variable name to use for pagination throughout the listings.'),
'#description' => $this->t('Variable name to use for pagination throughout the listings. This will get sent in the request.'),
'#states' => $states,
];
......@@ -152,7 +164,7 @@ class ApiBrowserServiceForm extends EntityForm {
'#type' => 'textfield',
'#title' => $this->t('Variable to use for per page limit'),
'#default_value' => $entity->get('listing')['per_page'] ?? '',
'#description' => $this->t('Variable name to use for pagination throughout the listings.'),
'#description' => $this->t('Variable name to use for pagination throughout the listings. This will get sent in the request.'),
'#states' => $states,
];
......@@ -167,7 +179,7 @@ class ApiBrowserServiceForm extends EntityForm {
$header = [
'parameter' => $this->t('Parameter'),
'value' => $this->t('Default Value'),
'value' => $this->t('Value'),
'remove' => $this->t('Remove'),
];
......@@ -236,7 +248,7 @@ class ApiBrowserServiceForm extends EntityForm {
];
$form['project']['enable'] = [
'#type' => 'checkbox',
'#title' => $this->t('Separate Endpoint for Project Info'),
'#title' => $this->t('Use a separate endpoint to get project info.'),
'#default_value' => $entity->get('project')['enable'] ?? FALSE,
];
......@@ -251,9 +263,9 @@ class ApiBrowserServiceForm extends EntityForm {
$form['project']['endpoint'] = [
'#type' => 'textfield',
'#title' => $this->t('Endpoint'),
'#title' => $this->t('Project Endpoint'),
'#default_value' => $entity->get('project')['endpoint'] ?? '',
'#description' => $this->t('Endpoint to return details about a project in the list. Variables can be used. Example {{ item_key }} refers to the key of the item in the list.'),
'#description' => $this->t('Endpoint to return details about a project in the list. Variables can be used. Example <code>{{ item_key }}</code> refers to the key of the item in the list.'),
'#required' => TRUE,
'#states' => $states,
];
......@@ -261,7 +273,7 @@ class ApiBrowserServiceForm extends EntityForm {
'#type' => 'select',
'#title' => $this->t('Request Method'),
'#default_value' => $entity->get('project')['method'] ?? '',
'#description' => $this->t('Request method to use for searching.'),
'#description' => $this->t('Method to use to get project details.'),
'#options' => [
'GET' => $this->t('GET'),
'POST' => $this->t('POST'),
......@@ -273,7 +285,7 @@ class ApiBrowserServiceForm extends EntityForm {
'#type' => 'select',
'#title' => $this->t('Authentication Type'),
'#default_value' => $entity->get('project')['auth'] ?? '',
'#description' => $this->t('Authentication method to use.'),
'#description' => $this->t('Authentication method to use for project details.'),
'#options' => $this->getAuthTypes(),
'#required' => TRUE,
'#ajax' => [
......@@ -306,7 +318,9 @@ class ApiBrowserServiceForm extends EntityForm {
'#type' => 'textfield',
'#title' => $this->t('Results Path'),
'#default_value' => $entity->get('project')['path'] ?? '',
'#description' => $this->t('Path to use for looping through the listings. Following should be in JSONPath format.'),
'#description' => $this->t('Path to use for getting project information. Following should be in @link format.', [
'@link' => Link::fromTextAndUrl('JMESPath', Url::fromUri('https://jmespath.org/'))->toString(),
]),
'#required' => TRUE,
'#states' => $states,
];
......@@ -319,9 +333,16 @@ class ApiBrowserServiceForm extends EntityForm {
$form['field_mapping']['description'] = [
'#type' => 'markup',
'#markup' =>
'<p>' . $this->t('The following section is used to map the fields from the response to the fields required for a Project Browser Project') . '</p>' .
'<p>' . $this->t('The fields below simple textfields that can leverage Twig to transform the data and apply changes to the requested data.') . '</p>',
'#markup' => implode('</p><p>',
[
$this->t('The following section is used to map the fields from the response to the fields required for a Project Browser Project'),
$this->t(
'The fields below are simple textareas that can leverage @url to transform the data and apply changes to the requested data.', [
'@url' => Link::fromTextAndUrl('Twig', Url::fromUri('https://twig.symfony.com/'))->toString(),
]),
$this->t('The variables refer to the keys that are returned for each project.'),
]
) . '</p>',
];
$form['field_mapping']['type'] = [
......@@ -330,6 +351,7 @@ class ApiBrowserServiceForm extends EntityForm {
'#title' => $this->t('Project Type'),
'#required' => TRUE,
'#default_value' => $entity->get('field_mapping')['type'] ?? '',
'#description' => $this->t('Type of the project. Module, Recipe, Theme, etc.'),
];
$form['field_mapping']['package_name'] = [
'#type' => 'textarea',
......@@ -337,6 +359,7 @@ class ApiBrowserServiceForm extends EntityForm {
'#title' => $this->t('Package Name'),
'#required' => TRUE,
'#default_value' => $entity->get('field_mapping')['package_name'] ?? '',
'#description' => $this->t('The name of the package to use for display.'),
];
$form['field_mapping']['machine_name'] = [
'#type' => 'textarea',
......@@ -344,6 +367,7 @@ class ApiBrowserServiceForm extends EntityForm {
'#title' => $this->t('Machine Name'),
'#required' => TRUE,
'#default_value' => $entity->get('field_mapping')['machine_name'] ?? '',
'#description' => $this->t('Machine name of project. This will be ran through a function to make this a machine name.'),
];
$form['field_mapping']['url'] = [
'#type' => 'textarea',
......@@ -351,6 +375,7 @@ class ApiBrowserServiceForm extends EntityForm {
'#title' => $this->t('URL to Project'),
'#required' => TRUE,
'#default_value' => $entity->get('field_mapping')['url'] ?? '',
'#description' => $this->t('URL for more project information.'),
];
$form['field_mapping']['logo'] = [
'#type' => 'textarea',
......@@ -358,6 +383,7 @@ class ApiBrowserServiceForm extends EntityForm {
'#title' => $this->t('Logo'),
'#required' => TRUE,
'#default_value' => $entity->get('field_mapping')['logo'] ?? '',
'#description' => $this->t('Url to use for the logo of the project.'),
];
$form['field_mapping']['title'] = [
'#type' => 'textarea',
......@@ -365,6 +391,7 @@ class ApiBrowserServiceForm extends EntityForm {
'#title' => $this->t('Title'),
'#required' => TRUE,
'#default_value' => $entity->get('field_mapping')['title'] ?? '',
'#description' => $this->t('The title of the project.'),
];
$form['field_mapping']['short_description'] = [
'#type' => 'textarea',
......@@ -372,6 +399,7 @@ class ApiBrowserServiceForm extends EntityForm {
'#title' => $this->t('Short Description'),
'#required' => TRUE,
'#default_value' => $entity->get('field_mapping')['short_description'] ?? '',
'#description' => $this->t('A short description for the project.'),
];
$form['field_mapping']['long_description'] = [
'#type' => 'textarea',
......@@ -379,6 +407,7 @@ class ApiBrowserServiceForm extends EntityForm {
'#title' => $this->t('Long Description'),
'#required' => TRUE,
'#default_value' => $entity->get('field_mapping')['long_description'] ?? '',
'#description' => $this->t('A long description for the project.'),
];
$form['field_mapping']['compatible'] = [
'#type' => 'textarea',
......@@ -386,6 +415,7 @@ class ApiBrowserServiceForm extends EntityForm {
'#title' => $this->t('Is Compatible?'),
'#required' => TRUE,
'#default_value' => $entity->get('field_mapping')['compatible'] ?? '',
'#description' => $this->t('Whether the project is compatible with the current version of Drupal. Following should return a true/false.'),
];
$form['field_mapping']['maintained'] = [
'#type' => 'textarea',
......@@ -393,6 +423,7 @@ class ApiBrowserServiceForm extends EntityForm {
'#title' => $this->t('Is Maintained'),
'#required' => TRUE,
'#default_value' => $entity->get('field_mapping')['maintained'] ?? '',
'#description' => $this->t('Whether the project is considered to be maintained or not. Following should return a true/false.'),
];
$form['field_mapping']['covered'] = [
'#type' => 'textarea',
......@@ -400,6 +431,7 @@ class ApiBrowserServiceForm extends EntityForm {
'#title' => $this->t('Is Covered'),
'#required' => TRUE,
'#default_value' => $entity->get('field_mapping')['covered'] ?? '',
'#description' => $this->t('Whether the project is considered to be covered or not. Following should return a true/false.'),
];
$form['field_mapping']['active'] = [
'#type' => 'textarea',
......@@ -407,6 +439,7 @@ class ApiBrowserServiceForm extends EntityForm {
'#title' => $this->t('Is Active'),
'#required' => TRUE,
'#default_value' => $entity->get('field_mapping')['active'] ?? '',
'#description' => $this->t('Whether the project is considered to be active or not. Following should return a true/false.'),
];
$form['field_mapping']['star_user_count'] = [
'#type' => 'textarea',
......@@ -414,6 +447,7 @@ class ApiBrowserServiceForm extends EntityForm {
'#title' => $this->t('Star User Count'),
'#required' => TRUE,
'#default_value' => $entity->get('field_mapping')['star_user_count'] ?? '',
'#description' => $this->t('User start count of the project. Should return a number.'),
];
$form['field_mapping']['project_usage_total'] = [
'#type' => 'textarea',
......@@ -421,6 +455,7 @@ class ApiBrowserServiceForm extends EntityForm {
'#title' => $this->t('Project Usage Total'),
'#required' => TRUE,
'#default_value' => $entity->get('field_mapping')['project_usage_total'] ?? '',
'#description' => $this->t('Total usage of the project. Should return a number.'),
];
$form['field_mapping']['created'] = [
'#type' => 'textarea',
......@@ -428,6 +463,7 @@ class ApiBrowserServiceForm extends EntityForm {
'#title' => $this->t('Created'),
'#required' => TRUE,
'#default_value' => $entity->get('field_mapping')['created'] ?? '',
'#description' => $this->t('When was the project created last timestamp. Should return a timestamp.'),
];
$form['field_mapping']['changed'] = [
'#type' => 'textarea',
......@@ -435,6 +471,7 @@ class ApiBrowserServiceForm extends EntityForm {
'#title' => $this->t('Changed'),
'#required' => TRUE,
'#default_value' => $entity->get('field_mapping')['changed'] ?? '',
'#description' => $this->t('When was the project changed last timestamp. Should return a timestamp.'),
];
$form['field_mapping']['author'] = [
'#type' => 'textarea',
......@@ -442,24 +479,28 @@ class ApiBrowserServiceForm extends EntityForm {
'#title' => $this->t('Author(s)'),
'#required' => TRUE,
'#default_value' => $entity->get('field_mapping')['author'] ?? '',
'#description' => $this->t('Author of the project. Should return a comma list of the authors.'),
];
$form['field_mapping']['categories'] = [
'#type' => 'textarea',
'#rows' => 2,
'#title' => $this->t('Categories'),
'#default_value' => $entity->get('field_mapping')['categories'] ?? '',
'#description' => $this->t('Categories of the project. Should return a comma list of categories.'),
];
$form['field_mapping']['images'] = [
'#type' => 'textarea',
'#rows' => 2,
'#title' => $this->t('Images'),
'#title' => $this->t('Project Images'),
'#default_value' => $entity->get('field_mapping')['images'] ?? '',
'#description' => $this->t('Images of the project. Should return a list of image urls in comma seperated format.'),
];
$form['field_mapping']['warnings'] = [
'#type' => 'textarea',
'#rows' => 2,
'#title' => $this->t('Warnings'),
'#title' => $this->t('Project Warnings'),
'#default_value' => $entity->get('field_mapping')['warnings'] ?? '',
'#description' => $this->t('Warnings for the project. Should return a comma seperated list.'),
];
return $form;
......@@ -488,6 +529,7 @@ class ApiBrowserServiceForm extends EntityForm {
$this->messenger()->addStatus($message);
$form_state->setRedirectUrl($this->entity->toUrl('collection'));
$this->projectBrowserSourceManager->clearCachedDefinitions();
Cache::invalidateTags(['api_browser:results:' . $this->entity->id()]);
return $result;
}
......
......@@ -62,14 +62,19 @@ class ApiBrowserProjectBrowserSource extends ProjectBrowserSourceBase {
*/
public function getProjects(array $query = []): ProjectsResultsPage {
$projects = $this->source?->getList($query) ?? [];
return $this->createResultsPage($projects);
$total = count($projects);
$offset = $query['page'] * $query['limit'];
// Extract the current page of the data we are looking for.
$page = array_slice($projects, $offset, $query['limit']);
return $this->createResultsPage($page, $total);
}
/**
* {@inheritDoc}
*/
public function getCategories(): array {
return [];
return $this->source?->getCategories() ?? [];
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment