diff --git a/CHANGELOG-3.0.md b/CHANGELOG-3.0.md index ea313f3359..41b95185c5 100644 --- a/CHANGELOG-3.0.md +++ b/CHANGELOG-3.0.md @@ -231,6 +231,8 @@ - Added `Zikula\Bundle\CoreBundle\Helper\LocalDotEnvHelper` to assist in writing to the `.env.local` file. - Added email notification to deleted pending registrations (#2915). - Added CLI Command to edit password, email, username properties of ZAuth user mappings (a replacement for the old Zikula Recovery Console). + - Added new Doctrine Paginator wrapper `Zikula\Bundle\CoreBundle\Doctrine\Paginator` and paginator template. See docs. + - Added new AlphaFilter class `Zikula\Bundle\CoreBundle\Filter\AlphaFilter` and template. See docs. - Vendor updates: - antishov/doctrine-extensions-bundle updated from 1.2.2 to 1.4.2 diff --git a/docs/LayoutDesign/Templating/Dev/AlphaFilter.md b/docs/LayoutDesign/Templating/Dev/AlphaFilter.md new file mode 100644 index 0000000000..bd81644157 --- /dev/null +++ b/docs/LayoutDesign/Templating/Dev/AlphaFilter.md @@ -0,0 +1,36 @@ +--- +currentMenu: templating +--- +# AlphaFilter + +In large result sets that are alphanumerically based, it is often helpful to display a filter selector that can +quickly link to results beginning with that letter or number. Zikula provides a quick method to do so. +In order to utilize Zikula's AlphaFilter, the following steps should be followed: + +### In the controller +```php +use Zikula\Bundle\CoreBundle\Filter\AlphaFilter; +// ... + +return [ + 'templateParam' => $value, + 'alpha' => new AlphaFilter('mycustomroute', $routeParameters, $currentLetter), +]; +``` + +### In the template + +```twig +{{ include(alpha.template) }} +``` + +### Options + +By default, the filter does not display digits. In order to enable them, add a fourth argument to the constructor: + +```php +new AlphaFilter('mycustomroute', $routeParameters, $currentLetter, true); +``` + +The template can be customized by overriding `@Core/Filter/AlphaFilter.html.twig` in all the normal ways. +You can also simply set your own custom template in the controller `$myAlphaFilter->setTemplate('@MyBundle/Custom/Template.html.twig');` diff --git a/docs/LayoutDesign/Templating/Dev/Pagination.md b/docs/LayoutDesign/Templating/Dev/Pagination.md new file mode 100644 index 0000000000..6ed9fdf301 --- /dev/null +++ b/docs/LayoutDesign/Templating/Dev/Pagination.md @@ -0,0 +1,44 @@ +--- +currentMenu: templating +--- +# Pagination + +Large result sets should be paginated. Doctrine provides a method to limit its result sets to do this, but it doesn't +provide an UI for proper display. Zikula provides a 'wrapper' class in order to facilitate easier UI. +In order to utilize Zikula's paginator, the following steps should be followed: + +### In the repository class + +```php +use Zikula\Bundle\CoreBundle\Doctrine\Paginator; +// ... + +$qb = $this->createQueryBuilder('m') + ->select('m'); +return (new Paginator($qb, $pageSize))->paginate($page); // returns Paginator object +``` + +### In the controller + +```php +$latestPosts = $repository->getLatestPosts($criteria, $pageSize); +$latestPosts->setRoute('mycustomroute'); +$latestPosts->setRouteParameters(['foo' => 'bar']); +return $this->render('blog/index.'.$_format.'.twig', [ + 'paginator' => $latestPosts, +]); +``` + +### In the template + +```twig +{% for post in paginator.results %} + {{ post.title }} +{% endfor %} +{{ include(paginator.template) }} +``` + +### Customization + +The template can be customized by overriding `@Core/Paginator/Paginator.html.twig` in all the normal ways. +You can also simply set your own custom template in the controller `$latestPosts->setTemplate('@MyBundle/Custom/Template.html.twig');` diff --git a/docs/LayoutDesign/Templating/README.md b/docs/LayoutDesign/Templating/README.md index b519e93e74..e7d354aadf 100644 --- a/docs/LayoutDesign/Templating/README.md +++ b/docs/LayoutDesign/Templating/README.md @@ -37,3 +37,5 @@ Zikula uses the Twig template engine, like Symfony does. ## For developers - [PageAssetApi](Dev/PageAssetApi.md) +- [Pagination of large result sets](Dev/Pagination.md) +- [Display an Alpha filter](Dev/AlphaFilter.md) diff --git a/src/Zikula/CoreBundle/Doctrine/Paginator.php b/src/Zikula/CoreBundle/Doctrine/Paginator.php new file mode 100644 index 0000000000..6e7851642e --- /dev/null +++ b/src/Zikula/CoreBundle/Doctrine/Paginator.php @@ -0,0 +1,174 @@ + + * Most of this file is copied from https://github.com/javiereguiluz/symfony-demo/blob/master/src/Pagination/Paginator.php + * + * usage: + * in Repository class: + * return (new Paginator($qb, $pageSize))->paginate($pageNumber); + * in controller: + * $latestPosts = $repository->getLatestPosts($criteria, $pageSize); + * return $this->render('blog/index.'.$_format.'.twig', [ + * 'paginator' => $latestPosts, + * ]); + * results in template {% for post in paginator.results %} + * include template: {{ include(paginator.template) }} + */ +class Paginator +{ + private const PAGE_SIZE = 25; + + private $queryBuilder; + + private $currentPage; + + private $pageSize; + + private $results; + + private $numResults; + + private $route; + + private $routeParameters; + + private $template = '@Core/Paginator/Paginator.html.twig'; + + public function __construct(DoctrineQueryBuilder $queryBuilder, int $pageSize = self::PAGE_SIZE) + { + $this->queryBuilder = $queryBuilder; + $this->pageSize = $pageSize; + } + + public function paginate(int $page = 1): self + { + $this->currentPage = max(1, $page); + $firstResult = ($this->currentPage - 1) * $this->pageSize; + + $query = $this->queryBuilder + ->setFirstResult($firstResult) + ->setMaxResults($this->pageSize) + ->getQuery(); + + if (0 === \count($this->queryBuilder->getDQLPart('join'))) { + $query->setHint(CountWalker::HINT_DISTINCT, false); + } + + $paginator = new DoctrinePaginator($query, true); + + $useOutputWalkers = \count($this->queryBuilder->getDQLPart('having') ?: []) > 0; + $paginator->setUseOutputWalkers($useOutputWalkers); + + $this->results = $paginator->getIterator(); + $this->numResults = $paginator->count(); + + return $this; + } + + public function getCurrentPage(): int + { + return $this->currentPage; + } + + public function getLastPage(): int + { + return (int) ceil($this->numResults / $this->pageSize); + } + + public function getPageSize(): int + { + return $this->pageSize; + } + + public function hasPreviousPage(): bool + { + return $this->currentPage > 1; + } + + public function getPreviousPage(): int + { + return max(1, $this->currentPage - 1); + } + + public function hasNextPage(): bool + { + return $this->currentPage < $this->getLastPage(); + } + + public function getNextPage(): int + { + return min($this->getLastPage(), $this->currentPage + 1); + } + + public function hasToPaginate(): bool + { + return $this->numResults > $this->pageSize; + } + + public function getNumResults(): int + { + return $this->numResults; + } + + public function getResults(): \Traversable + { + return $this->results; + } + + public function setRoute(string $route): self + { + $this->route = $route; + + return $this; + } + + public function getRoute(): string + { + return $this->route; + } + + public function setRouteParameters(array $parameters): self + { + $this->routeParameters = $parameters; + + return $this; + } + + public function setRouteParameter(string $name, string $value): void + { + $this->routeParameters[$name] = $value; + } + + public function getRouteParameters(): array + { + return $this->routeParameters; + } + + public function setTemplate(string $templateName): void + { + $this->template = $templateName; + } + + public function getTemplate(): string + { + return $this->template; + } +} diff --git a/src/Zikula/CoreBundle/Filter/AlphaFilter.php b/src/Zikula/CoreBundle/Filter/AlphaFilter.php new file mode 100644 index 0000000000..02ade60b0a --- /dev/null +++ b/src/Zikula/CoreBundle/Filter/AlphaFilter.php @@ -0,0 +1,99 @@ + $value, + * 'alpha' => new AlphaFilter('mycustomroute', $routeParameters, $currentLetter), + * ]; + * In template: + * {{ include(alpha.template) }} + */ +class AlphaFilter +{ + private $currentLetter; + + private $route; + + private $routeParameters; + + private $template = '@Core/Filter/AlphaFilter.html.twig'; + + private $includeNumbers = false; + + public function __construct(string $route, array $routeParameters = [], $currentLetter = 'a', $includeNumbers = false) + { + $this->route = $route; + $this->routeParameters = $routeParameters; + $this->currentLetter = $currentLetter; + $this->includeNumbers = $includeNumbers; + } + + public function getCurrentLetter(): string + { + return $this->currentLetter; + } + + public function setRoute(string $route): self + { + $this->route = $route; + + return $this; + } + + public function getRoute(): string + { + return $this->route; + } + + public function setRouteParameters(array $parameters): self + { + $this->routeParameters = $parameters; + + return $this; + } + + public function setRouteParameter(string $name, ?string $value): void + { + $this->routeParameters[$name] = $value; + } + + public function getRouteParameters(): array + { + return $this->routeParameters; + } + + public function setTemplate(string $templateName): void + { + $this->template = $templateName; + } + + public function getTemplate(): string + { + return $this->template; + } + + public function setIncludeNumbers(bool $include): void + { + $this->includeNumbers = $include; + } + + public function getIncludeNumbers(): bool + { + return $this->includeNumbers; + } +} diff --git a/src/Zikula/CoreBundle/Resources/views/Filter/AlphaFilter.html.twig b/src/Zikula/CoreBundle/Resources/views/Filter/AlphaFilter.html.twig new file mode 100644 index 0000000000..2d04d1f080 --- /dev/null +++ b/src/Zikula/CoreBundle/Resources/views/Filter/AlphaFilter.html.twig @@ -0,0 +1,43 @@ +{% if alpha.route is empty and app.request.attributes.has('_route') %} + {% set alpha = alpha|merge({ ('route'): app.request.attributes.get('_route')}) %} +{% endif %} +{% if alpha.routeParameters is empty and app.request.attributes.has('_route_params') %} + {% set alpha = alpha|merge({ ('routeParameters'): app.request.attributes.get('_route_params')}) %} +{% endif %} + diff --git a/src/Zikula/CoreBundle/Resources/views/Paginator/Paginator.html.twig b/src/Zikula/CoreBundle/Resources/views/Paginator/Paginator.html.twig new file mode 100644 index 0000000000..2c4f2c4209 --- /dev/null +++ b/src/Zikula/CoreBundle/Resources/views/Paginator/Paginator.html.twig @@ -0,0 +1,63 @@ +{% if paginator.route is empty and app.request.attributes.has('_route') %} + {% do paginator.setRoute(app.request.attributes.get('_route')) %} +{% endif %} +{% if paginator.hasToPaginate %} + +{% endif %} diff --git a/src/system/ZAuthModule/Controller/UserAdministrationController.php b/src/system/ZAuthModule/Controller/UserAdministrationController.php index 8b5240cec5..402d0bfd60 100644 --- a/src/system/ZAuthModule/Controller/UserAdministrationController.php +++ b/src/system/ZAuthModule/Controller/UserAdministrationController.php @@ -25,6 +25,7 @@ use Translation\Extractor\Annotation\Desc; use Zikula\Bundle\CoreBundle\Controller\AbstractController; use Zikula\Bundle\CoreBundle\Event\GenericEvent; +use Zikula\Bundle\CoreBundle\Filter\AlphaFilter; use Zikula\Bundle\CoreBundle\Response\PlainResponse; use Zikula\Bundle\HookBundle\Dispatcher\HookDispatcherInterface; use Zikula\Bundle\HookBundle\Hook\ProcessHook; @@ -70,7 +71,7 @@ class UserAdministrationController extends AbstractController { /** - * @Route("/list/{sort}/{sortdir}/{letter}/{startnum}") + * @Route("/list/{sort}/{sortdir}/{letter}/{page}") * @PermissionCheck("moderate") * @Theme("admin") * @Template("@ZikulaZAuthModule/UserAdministration/list.html.twig") @@ -83,34 +84,35 @@ public function listAction( string $sort = 'uid', string $sortdir = 'DESC', string $letter = 'all', - int $startnum = 0 + int $page = 1 ): array { - $startnum = $startnum > 0 ? $startnum - 1 : 0; - $sortableColumns = new SortableColumns($router, 'zikulazauthmodule_useradministration_list', 'sort', 'sortdir'); $sortableColumns->addColumns([new Column('uname'), new Column('uid')]); $sortableColumns->setOrderByFromRequest($request); $sortableColumns->setAdditionalUrlParameters([ 'letter' => $letter, - 'startnum' => $startnum + 'page' => $page ]); $filter = []; if (!empty($letter) && 'all' !== $letter) { $filter['uname'] = ['operator' => 'like', 'operand' => "${letter}%"]; } - $limit = 25; - - $mappings = $authenticationMappingRepository->query($filter, [$sort => $sortdir], $limit, $startnum); + $itemsPerPage = $this->getVar(ZAuthConstant::MODVAR_ITEMS_PER_PAGE, ZAuthConstant::DEFAULT_ITEMS_PER_PAGE); + $paginator = $authenticationMappingRepository->query($filter, [$sort => $sortdir], 'and', $page, $itemsPerPage); + $paginator->setRoute('zikulazauthmodule_useradministration_list'); + $routeParameters = [ + 'sort' => $sort, + 'sortdir' => $sortdir, + 'letter' => $letter, + ]; + $paginator->setRouteParameters($routeParameters); return [ 'sort' => $sortableColumns->generateSortableColumns(), - 'pager' => [ - 'count' => $mappings->count(), - 'limit' => $limit - ], 'actionsHelper' => $actionsHelper, - 'mappings' => $mappings + 'alpha' => new AlphaFilter('zikulazauthmodule_useradministration_list', $routeParameters, $letter), + 'paginator' => $paginator ]; } @@ -135,7 +137,7 @@ public function getUsersByFragmentAsTableAction( $mappings = $authenticationMappingRepository->query($filter); return $this->render('@ZikulaZAuthModule/UserAdministration/userlist.html.twig', [ - 'mappings' => $mappings, + 'mappings' => $mappings->getResults(), 'actionsHelper' => $actionsHelper ], new PlainResponse()); } diff --git a/src/system/ZAuthModule/Entity/Repository/AuthenticationMappingRepository.php b/src/system/ZAuthModule/Entity/Repository/AuthenticationMappingRepository.php index 4a18838ba0..e468d04c61 100644 --- a/src/system/ZAuthModule/Entity/Repository/AuthenticationMappingRepository.php +++ b/src/system/ZAuthModule/Entity/Repository/AuthenticationMappingRepository.php @@ -15,8 +15,8 @@ use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\Query\Expr\OrderBy; -use Doctrine\ORM\Tools\Pagination\Paginator; use Doctrine\Persistence\ManagerRegistry; +use Zikula\Bundle\CoreBundle\Doctrine\Paginator; use Zikula\Bundle\CoreBundle\Doctrine\WhereFromFilterTrait; use Zikula\ZAuthModule\Entity\AuthenticationMappingEntity; use Zikula\ZAuthModule\Entity\RepositoryInterface\AuthenticationMappingRepositoryInterface; @@ -60,19 +60,17 @@ public function setEmailVerification(int $userId, bool $value = true): void } /** - * Fetch a collection of users. Optionally filter, sort, limit, offset results. + * Fetch a collection of users. Optionally filter, sort results. * filter = [field => value, field => value, field => ['operator' => '!=', 'operand' => value], ...] * when value is not an array, operator is assumed to be '=' - * - * @return Paginator|AuthenticationMappingEntity[] */ public function query( array $filter = [], array $sort = [], - int $limit = 0, - int $offset = 0, - string $exprType = 'and' - ) { + string $exprType = 'and', + int $page = 1, + int $pageSize = 25 + ): Paginator { $qb = $this->createQueryBuilder('m') ->select('m'); if (!empty($filter)) { @@ -82,16 +80,8 @@ public function query( if (!empty($sort)) { $qb->orderBy($this->orderByFromArray($sort)); } - $query = $qb->getQuery(); - if ($limit > 0) { - $query->setMaxResults($limit); - $query->setFirstResult($offset); - - return new Paginator($query); - } - - return $query->getResult(); + return (new Paginator($qb, $pageSize))->paginate($page); } public function getByExpiredPasswords() diff --git a/src/system/ZAuthModule/Entity/RepositoryInterface/AuthenticationMappingRepositoryInterface.php b/src/system/ZAuthModule/Entity/RepositoryInterface/AuthenticationMappingRepositoryInterface.php index 5543f42966..cf90ba502e 100644 --- a/src/system/ZAuthModule/Entity/RepositoryInterface/AuthenticationMappingRepositoryInterface.php +++ b/src/system/ZAuthModule/Entity/RepositoryInterface/AuthenticationMappingRepositoryInterface.php @@ -14,8 +14,8 @@ namespace Zikula\ZAuthModule\Entity\RepositoryInterface; use Doctrine\Common\Collections\Selectable; -use Doctrine\ORM\Tools\Pagination\Paginator; use Doctrine\Persistence\ObjectRepository; +use Zikula\Bundle\CoreBundle\Doctrine\Paginator; use Zikula\ZAuthModule\Entity\AuthenticationMappingEntity; interface AuthenticationMappingRepositoryInterface extends ObjectRepository, Selectable @@ -29,15 +29,15 @@ public function getByZikulaId(int $userId): AuthenticationMappingEntity; public function setEmailVerification(int $userId, bool $value = true): void; /** - * @return Paginator|AuthenticationMappingEntity[] + * @return Paginator */ public function query( array $filter = [], array $sort = [], - int $limit = 0, - int $offset = 0, - string $exprType = 'and' - ); + string $exprType = 'and', + int $page = 1, + int $pageSize = 25 + ): Paginator; public function getByExpiredPasswords(); } diff --git a/src/system/ZAuthModule/Form/Type/ConfigType.php b/src/system/ZAuthModule/Form/Type/ConfigType.php index 3fed9ed432..06e59532ae 100644 --- a/src/system/ZAuthModule/Form/Type/ConfigType.php +++ b/src/system/ZAuthModule/Form/Type/ConfigType.php @@ -120,6 +120,14 @@ public function buildForm(FormBuilderInterface $builder, array $options) new Type('string') ] ]) + ->add(ZAuthConstant::MODVAR_ITEMS_PER_PAGE, IntegerType::class, [ + 'label' => 'Number of users displayed per page', + 'help' => 'When lists are displayed (for example, lists of users, lists of registrations) this option controls how many items are displayed at one time.', + 'constraints' => [ + new NotBlank(), + new GreaterThanOrEqual(['value' => 1]) + ] + ]) ->add('save', SubmitType::class, [ 'label' => 'Save', 'icon' => 'fa-check', diff --git a/src/system/ZAuthModule/Resources/views/Config/config.html.twig b/src/system/ZAuthModule/Resources/views/Config/config.html.twig index 344275b13e..d575dd222e 100644 --- a/src/system/ZAuthModule/Resources/views/Config/config.html.twig +++ b/src/system/ZAuthModule/Resources/views/Config/config.html.twig @@ -24,6 +24,10 @@ {{ form_row(form[constant('MODVAR_REGISTRATION_ANTISPAM_ANSWER', ZAC)]) }} +
+ {% trans %}General Settings{% endtrans %} + {{ form_row(form[constant('MODVAR_ITEMS_PER_PAGE', ZAC)]) }} +
{{ form_widget(form.save) }} diff --git a/src/system/ZAuthModule/Resources/views/UserAdministration/list.html.twig b/src/system/ZAuthModule/Resources/views/UserAdministration/list.html.twig index 604b1b3782..45453dc6e7 100644 --- a/src/system/ZAuthModule/Resources/views/UserAdministration/list.html.twig +++ b/src/system/ZAuthModule/Resources/views/UserAdministration/list.html.twig @@ -16,9 +16,7 @@

{% trans %}Please enter at least 3 characters{% endtrans %}

-

- {{ pagerabc({posvar: 'letter', forwardvars: 'sortby', printempty: true, route: 'zikulazauthmodule_useradministration_list'}) }} -

+
{{ include(alpha.template) }}
@@ -41,10 +39,10 @@ - {{ include('@ZikulaZAuthModule/UserAdministration/userlist.html.twig') }} + {{ include('@ZikulaZAuthModule/UserAdministration/userlist.html.twig', {mappings: paginator.results}) }}
- {{ pager({rowcount: pager.count, limit: pager.limit, route: 'zikulazauthmodule_useradministration_list', posvar: 'startnum'}) }} + {{ include(paginator.template) }}
{# This table is for ajax search results and is hidden until needed and populated then with the results. #} diff --git a/src/system/ZAuthModule/ZAuthConstant.php b/src/system/ZAuthModule/ZAuthConstant.php index ac27231c19..9b29636299 100644 --- a/src/system/ZAuthModule/ZAuthConstant.php +++ b/src/system/ZAuthModule/ZAuthConstant.php @@ -135,4 +135,14 @@ class ZAuthConstant * Set value to (bool) TRUE if change is required. The existence of the key and the value are both tested. */ public const REQUIRE_PASSWORD_CHANGE_KEY = '_Users_mustChangePassword'; + + /** + * Module variable key for the number of items (e.g., records) to display per list "page." + */ + public const MODVAR_ITEMS_PER_PAGE = 'itemsperpage'; + + /** + * Default value for the number of items (e.g., records) to display per list "page." + */ + public const DEFAULT_ITEMS_PER_PAGE = 25; } diff --git a/src/system/ZAuthModule/ZAuthModuleInstaller.php b/src/system/ZAuthModule/ZAuthModuleInstaller.php index d5a3c6923f..40e86cf097 100644 --- a/src/system/ZAuthModule/ZAuthModuleInstaller.php +++ b/src/system/ZAuthModule/ZAuthModuleInstaller.php @@ -64,6 +64,7 @@ public function upgrade(string $oldVersion): bool $this->delVar('hash_method'); $this->setVar(ZAuthConstant::MODVAR_REQUIRE_NON_COMPROMISED_PASSWORD, ZAuthConstant::DEFAULT_REQUIRE_UNCOMPROMISED_PASSWORD); case '1.0.2': + $this->setVar(ZAuthConstant::MODVAR_ITEMS_PER_PAGE, ZAuthConstant::DEFAULT_ITEMS_PER_PAGE); // current version } @@ -92,6 +93,7 @@ private function getDefaultModvars(): array ZAuthConstant::MODVAR_EMAIL_VERIFICATION_REQUIRED => ZAuthConstant::DEFAULT_EMAIL_VERIFICATION_REQUIRED, ZAuthConstant::MODVAR_REGISTRATION_ANTISPAM_ANSWER => '', ZAuthConstant::MODVAR_REGISTRATION_ANTISPAM_QUESTION => '', + ZAuthConstant::MODVAR_ITEMS_PER_PAGE => ZAuthConstant::DEFAULT_ITEMS_PER_PAGE, ]; } } diff --git a/translations/routes.en.yaml b/translations/routes.en.yaml index d10ee28428..f8612f720d 100644 --- a/translations/routes.en.yaml +++ b/translations/routes.en.yaml @@ -154,7 +154,7 @@ zikulazauthmodule_account_changepassword: /account/change-password zikulazauthmodule_config_config: /admin/config zikulazauthmodule_fileio_import: /fileIO/import zikulazauthmodule_registration_verify: '/verify-registration/{uname}/{verifycode}' -zikulazauthmodule_useradministration_list: '/admin/list/{sort}/{sortdir}/{letter}/{startnum}' +zikulazauthmodule_useradministration_list: '/admin/list/{sort}/{sortdir}/{letter}/{page}' zikulazauthmodule_useradministration_create: /admin/user/create zikulazauthmodule_useradministration_modify: '/admin/user/modify/{mapping}' zikulazauthmodule_useradministration_verify: '/admin/verify/{mapping}'