diff --git a/config/widgets-themes.php b/config/widgets-themes.php index d799a9098..39fa0dcad 100644 --- a/config/widgets-themes.php +++ b/config/widgets-themes.php @@ -8,10 +8,25 @@ use Yiisoft\Yii\DataView\GridView; use Yiisoft\Yii\DataView\KeysetPagination; use Yiisoft\Yii\DataView\OffsetPagination; +use Yiisoft\Yii\DataView\PageSize\InputPageSize; +use Yiisoft\Yii\DataView\PageSize\SelectPageSize; return [ 'bootstrap5' => [ GridView::class => [ + 'layout()' => [ + << +
{pager}
+
{pageSize}
+ + HTML + ], + 'pageSizeTag()' => [null], 'summaryTag()' => ['p'], 'summaryAttributes()' => [['class' => 'text-secondary']], 'tableClass()' => ['table table-bordered'], @@ -54,5 +69,11 @@ 'currentItemClass()' => ['active'], 'disabledItemClass()' => ['disabled'], ], + InputPageSize::class => [ + 'attributes()' => [['class' => 'form-control d-inline text-center mx-2', 'style' => 'width:60px']], + ], + SelectPageSize::class => [ + 'attributes()' => [['class' => 'form-select w-auto d-inline mx-2']], + ], ], ]; diff --git a/src/BaseListView.php b/src/BaseListView.php index b4adcb7bb..227c9b840 100644 --- a/src/BaseListView.php +++ b/src/BaseListView.php @@ -33,16 +33,25 @@ use Yiisoft\Widget\Widget; use Yiisoft\Data\Reader\OrderHelper; use Yiisoft\Yii\DataView\Exception\DataReaderNotSetException; +use Yiisoft\Yii\DataView\PageSize\InputPageSize; +use Yiisoft\Yii\DataView\PageSize\PageSizeContext; +use Yiisoft\Yii\DataView\PageSize\PageSizeControlInterface; +use Yiisoft\Yii\DataView\PageSize\SelectPageSize; +use function array_key_exists; use function array_slice; +use function call_user_func_array; use function extension_loaded; +use function in_array; use function is_array; +use function is_int; use function is_string; /** * @psalm-type UrlArguments = array * @psalm-type UrlCreator = callable(UrlArguments,array):string * @psalm-type PageNotFoundExceptionCallback = callable(PageNotFoundException):void + * @psalm-type PageSizeConstraint = list|positive-int|bool * @psalm-import-type TOrder from Sort */ abstract class BaseListView extends Widget @@ -53,8 +62,34 @@ abstract class BaseListView extends Widget protected $urlCreator = null; protected UrlConfig $urlConfig; + /** + * @var int Page size that is used in case it is not set explicitly. + * @psalm-var positive-int + */ protected int $defaultPageSize = PaginatorInterface::DEFAULT_PAGE_SIZE; + /** + * @var array|bool|int Page size constraint. + * - `true` - default only. + * - `false` - no constraint. + * - int - maximum page size. + * - [int, int, ...] - a list of page sizes to choose from. + * + * @see PageSizeContext::FIXED_VALUE + * @see PageSizeContext::ANY_VALUE + * + * @psalm-var PageSizeConstraint + */ + protected bool|int|array $pageSizeConstraint = true; + + /** + * @psalm-var non-empty-string|null + */ + private ?string $pageSizeTag = 'div'; + private array $pageSizeAttributes = []; + private ?string $pageSizeTemplate = 'Results per page {control}'; + private PageSizeControlInterface|null $pageSizeControl = null; + /** * A name for {@see CategorySource} used with translator ({@see TranslatorInterface}) by default. * @psalm-suppress MissingClassConstType @@ -84,7 +119,7 @@ abstract class BaseListView extends Widget private array $emptyTextAttributes = []; private string $header = ''; private array $headerAttributes = []; - private string $layout = "{header}\n{toolbar}\n{items}\n{summary}\n{pager}"; + private string $layout = "{header}\n{toolbar}\n{items}\n{summary}\n{pager}\n{pageSize}"; private array $offsetPaginationConfig = []; private array $keysetPaginationConfig = []; @@ -372,9 +407,9 @@ private function prepareDataReaderByParams( } if ($dataReader->isPaginationRequired()) { - if ($pageSize !== null) { - $dataReader = $dataReader->withPageSize((int) $pageSize); - } + $dataReader = $dataReader->withPageSize( + $this->preparePageSize($pageSize) ?? $this->getDefaultPageSize() + ); if ($page !== null) { $dataReader = $dataReader->withToken(PageToken::next($page)); @@ -468,6 +503,53 @@ public function layout(string $value): static return $new; } + final public function pageSizeTag(?string $tag): static + { + if ($tag === '') { + throw new InvalidArgumentException('Tag name cannot be empty.'); + } + + $new = clone $this; + $new->pageSizeTag = $tag; + return $new; + } + + /** + * Returns a new instance with the HTML attributes for page size wrapper tag. + * + * @param array $values Attribute values indexed by attribute names. + */ + final public function pageSizeAttributes(array $values): static + { + $new = clone $this; + $new->pageSizeAttributes = $values; + return $new; + } + + /** + * Returns a new instance with the page size template. + * + * @param string|null $template The HTML content to be displayed as the page size control. If you don't want to show + * control, you may set it with an empty string or null. + * + * The following tokens will be replaced with the corresponding values: + * + * - `{control}` — page size control. + */ + final public function pageSizeTemplate(?string $template): static + { + $new = clone $this; + $new->pageSizeTemplate = $template; + return $new; + } + + final public function pageSizeControl(?PageSizeControlInterface $widget): static + { + $new = clone $this; + $new->pageSizeControl = $widget; + return $new; + } + public function pagination(string|KeysetPagination|OffsetPagination|null $pagination): static { $new = clone $this; @@ -627,6 +709,7 @@ public function render(): string '{items}' => $this->renderItems($items, $filterValidationResult), '{summary}' => $this->renderSummary(), '{pager}' => $this->renderPagination(), + '{pageSize}' => $this->renderPageSize(), ], ) ); @@ -638,14 +721,48 @@ public function render(): string ->render(); } + /** + * @psalm-return positive-int + */ protected function getDefaultPageSize(): int { $dataReader = $this->getDataReader(); - if ($dataReader instanceof PaginatorInterface) { - return $dataReader->getPageSize(); + $pageSize = $dataReader instanceof PaginatorInterface + ? $dataReader->getPageSize() + : $this->defaultPageSize; + + if (is_int($this->pageSizeConstraint)) { + return $pageSize <= $this->pageSizeConstraint + ? $pageSize + : $this->pageSizeConstraint; + } + + if (is_array($this->pageSizeConstraint)) { + return in_array($pageSize, $this->pageSizeConstraint, true) + ? $pageSize + : $this->pageSizeConstraint[0]; } - return $this->defaultPageSize; + return $pageSize; + } + + /** + * Get a new instance with a page size constraint set. + * + * @param array|bool|int $pageSizeConstraint Page size constraint. + * `true` - default only. + * `false` - no constraint. + * int - maximum page size. + * [int, int, ...] - a list of page sizes to choose from. + * @return static New instance. + * + * @psalm-param PageSizeConstraint $pageSizeConstraint + */ + public function pageSizeConstraint(array|int|bool $pageSizeConstraint): static + { + $new = clone $this; + $new->pageSizeConstraint = $pageSizeConstraint; + return $new; } /** @@ -705,6 +822,69 @@ private function renderPagination(): string ->render(); } + private function renderPageSize(): string + { + if (empty($this->pageSizeTemplate)) { + return ''; + } + + $dataReader = $this->preparedDataReader; + if (!$dataReader instanceof PaginatorInterface) { + return ''; + } + + if ($this->pageSizeControl === null) { + if ($this->pageSizeConstraint === false || is_int($this->pageSizeConstraint)) { + $widget = InputPageSize::widget(); + } elseif (is_array($this->pageSizeConstraint)) { + $widget = SelectPageSize::widget(); + } else { + return ''; + } + } else { + $widget = $this->pageSizeControl; + } + + if ($this->urlCreator === null) { + $urlPattern = '#pagesize=' . PageSizeContext::URL_PLACEHOLDER; + $defaultUrl = '#'; + } else { + $sort = $this->getSortValueForUrl($dataReader); + $urlPattern = call_user_func_array( + $this->urlCreator, + UrlParametersFactory::create( + null, + PageSizeContext::URL_PLACEHOLDER, + $sort, + $this->urlConfig + ), + ); + $defaultUrl = call_user_func_array( + $this->urlCreator, + UrlParametersFactory::create(null, null, $sort, $this->urlConfig), + ); + } + + $context = new PageSizeContext( + $dataReader->getPageSize(), + $this->getDefaultPageSize(), + $this->pageSizeConstraint, + $urlPattern, + $defaultUrl, + ); + $control = $widget->withContext($context)->render(); + + $content = $this->translator->translate( + $this->pageSizeTemplate, + ['control' => $control], + $this->translationCategory, + ); + + return $this->pageSizeTag === null + ? $content + : Html::tag($this->pageSizeTag, $content, $this->pageSizeAttributes)->encode(false)->render(); + } + private function renderSummary(): string { if (empty($this->summaryTemplate)) { @@ -767,6 +947,39 @@ private function renderHeader(): string }; } + /** + * @psalm-return positive-int|null + */ + private function preparePageSize(?string $rawPageSize): ?int + { + if ($this->pageSizeConstraint === true) { + return null; + } + + if ($rawPageSize === null) { + return null; + } + + $pageSize = (int) $rawPageSize; + if ($pageSize < 1) { + return null; + } + + if ($this->pageSizeConstraint === false) { + return $pageSize; + } + + if (is_int($this->pageSizeConstraint) && $pageSize <= $this->pageSizeConstraint) { + return $pageSize; + } + + if (is_array($this->pageSizeConstraint) && in_array($pageSize, $this->pageSizeConstraint, true)) { + return $pageSize; + } + + return null; + } + /** * Creates default translator to use if {@see $translator} wasn't set explicitly in the constructor. Depending on * "intl" extension availability, either {@see IntlMessageFormatter} or {@see SimpleMessageFormatter} is used as @@ -786,4 +999,41 @@ private function createDefaultTranslator(): Translator return $translator; } + + private function getSortValueForUrl(PaginatorInterface $paginator): ?string + { + $sort = $this->getSort($paginator); + if ($sort === null) { + return null; + } + + $originalSort = $this->getSort($this->dataReader); + if ($originalSort?->getOrderAsString() === $sort->getOrderAsString()) { + return null; + } + + $order = []; + $overrideOrderFields = array_flip($this->getOverrideOrderFields()); + foreach ($sort->getOrder() as $name => $value) { + $key = array_key_exists($name, $overrideOrderFields) + ? $overrideOrderFields[$name] + : $name; + $order[$key] = $value; + } + + return OrderHelper::arrayToString($order); + } + + private function getSort(?ReadableDataInterface $dataReader): ?Sort + { + if ($dataReader instanceof PaginatorInterface && $dataReader->isSortable()) { + return $dataReader->getSort(); + } + + if ($dataReader instanceof SortableDataInterface) { + return $dataReader->getSort(); + } + + return null; + } } diff --git a/src/BasePagination.php b/src/BasePagination.php index a6764d4f3..796b562ae 100644 --- a/src/BasePagination.php +++ b/src/BasePagination.php @@ -213,6 +213,9 @@ public function urlConfig(UrlConfig $config): static /** * Creates the URL suitable for pagination with the specified page number. This method is mainly called by pagers * when creating URLs used to perform pagination. + * + * @param PageToken $pageToken Token for the page. + * @return string Created URL. */ protected function createUrl(PageToken $pageToken): string { diff --git a/src/GridView.php b/src/GridView.php index 02611b541..79b6cc1ec 100644 --- a/src/GridView.php +++ b/src/GridView.php @@ -30,6 +30,7 @@ use Yiisoft\Yii\DataView\Filter\Factory\IncorrectValueException; use function call_user_func; +use function call_user_func_array; use function is_callable; /** diff --git a/src/PageSize/InputPageSize.php b/src/PageSize/InputPageSize.php new file mode 100644 index 000000000..4a778b732 --- /dev/null +++ b/src/PageSize/InputPageSize.php @@ -0,0 +1,57 @@ +attributes = array_merge($new->attributes, $attributes); + return $new; + } + + /** + * Replace SELECT tag attributes with a new set. + * + * @param array $attributes Name-value set of attributes. + */ + public function attributes(array $attributes): self + { + $new = clone $this; + $new->attributes = $attributes; + return $new; + } + + public function render(): string + { + $context = $this->getContext(); + + $attributes = array_merge($this->attributes, [ + 'data-default-page-size' => $context->defaultValue, + 'data-url-pattern' => $context->urlPattern, + 'data-default-url' => $context->defaultUrl, + 'onchange' => 'window.location.href = this.value == this.dataset.defaultPageSize ? this.dataset.defaultUrl : this.dataset.urlPattern.replace("' . PageSizeContext::URL_PLACEHOLDER . '", this.value)', + ]); + + return Html::textInput( + value: $context->currentValue, + attributes: $attributes + )->render(); + } +} diff --git a/src/PageSize/PageSizeContext.php b/src/PageSize/PageSizeContext.php new file mode 100644 index 000000000..b4b531b79 --- /dev/null +++ b/src/PageSize/PageSizeContext.php @@ -0,0 +1,40 @@ +context = $context; + return $new; + } + + final protected function getContext(): PageSizeContext + { + if ($this->context === null) { + throw new LogicException('Context is not set.'); + } + return $this->context; + } +} diff --git a/src/PageSize/PageSizeControlInterface.php b/src/PageSize/PageSizeControlInterface.php new file mode 100644 index 000000000..bd5a2e547 --- /dev/null +++ b/src/PageSize/PageSizeControlInterface.php @@ -0,0 +1,14 @@ +attributes = array_merge($new->attributes, $attributes); + return $new; + } + + /** + * Replace SELECT tag attributes with a new set. + * + * @param array $attributes Name-value set of attributes. + */ + public function attributes(array $attributes): self + { + $new = clone $this; + $new->attributes = $attributes; + return $new; + } + + public function render(): string + { + $context = $this->getContext(); + if (!is_array($context->constraint) || count($context->constraint) < 2) { + return ''; + } + + $options = []; + foreach ($context->constraint as $value) { + $options[$value] = (string) $value; + } + + $attributes = array_merge($this->attributes, [ + 'data-default-page-size' => $context->defaultValue, + 'data-url-pattern' => $context->urlPattern, + 'data-default-url' => $context->defaultUrl, + 'onchange' => 'window.location.href = this.value == this.dataset.defaultPageSize ? this.dataset.defaultUrl : this.dataset.urlPattern.replace("' . PageSizeContext::URL_PLACEHOLDER . '", this.value)', + ]); + + return Html::select() + ->optionsData($options, encode: false) + ->value($context->currentValue) + ->attributes($attributes) + ->render(); + } +} diff --git a/src/UrlParametersFactory.php b/src/UrlParametersFactory.php index 2047f26e4..a3174225e 100644 --- a/src/UrlParametersFactory.php +++ b/src/UrlParametersFactory.php @@ -18,7 +18,7 @@ final class UrlParametersFactory */ public static function create( ?PageToken $pageToken, - int|null $pageSize, + int|string|null $pageSize, string|null $sort, UrlConfig $context, ): array { diff --git a/tests/Support/SimpleUrlParameterProvider.php b/tests/Support/SimpleUrlParameterProvider.php new file mode 100644 index 000000000..13d684202 --- /dev/null +++ b/tests/Support/SimpleUrlParameterProvider.php @@ -0,0 +1,23 @@ +parameters[$name] ?? null; + } +}