Issues (13)

src/GridView.php (2 issues)

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\DataView;
6
7
use Closure;
8
use Psr\Container\ContainerInterface;
9
use Stringable;
10
use Yiisoft\Definitions\Exception\CircularReferenceException;
11
use Yiisoft\Definitions\Exception\InvalidConfigException;
12
use Yiisoft\Definitions\Exception\NotInstantiableException;
13
use Yiisoft\Factory\NotFoundException;
14
use Yiisoft\Html\Html;
15
use Yiisoft\Html\Tag\Tr;
16
use Yiisoft\Router\UrlGeneratorInterface;
17
use Yiisoft\Translator\TranslatorInterface;
18
use Yiisoft\Yii\DataView\Column\ActionColumn;
19
use Yiisoft\Yii\DataView\Column\Base\Cell;
20
use Yiisoft\Yii\DataView\Column\Base\GlobalContext;
21
use Yiisoft\Yii\DataView\Column\Base\DataContext;
22
use Yiisoft\Yii\DataView\Column\ColumnInterface;
23
use Yiisoft\Yii\DataView\Column\ColumnRendererInterface;
24
use Yiisoft\Yii\DataView\Column\DataColumn;
25
26
/**
27
 * The GridView widget is used to display data in a grid.
28
 *
29
 * It provides features like {@see sorter|sorting}, and {@see filterModel|filtering} the data.
30
 *
31
 * The columns of the grid table are configured in terms of {@see Column} classes, which are configured via
32
 * {@see columns}.
33
 *
34
 * The look and feel of a grid view can be customized using the large amount of properties.
35
 */
36
final class GridView extends BaseListView
37
{
38
    public const FILTER_POS_HEADER = 'header';
39
    public const FILTER_POS_FOOTER = 'footer';
40
    public const FILTER_POS_BODY = 'body';
41
42
    private Closure|null $afterRow = null;
43
    private Closure|null $beforeRow = null;
44
45
    /**
46
     * @var ColumnInterface[]
47
     */
48
    private array $columns = [];
49
50
    private bool $columnsGroupEnabled = false;
51
    private string $emptyCell = '&nbsp;';
52
    private ?string $filterModelName = null;
53
    private string $filterPosition = self::FILTER_POS_BODY;
54
    private array $filterRowAttributes = [];
55
    private bool $footerEnabled = false;
56
    private array $footerRowAttributes = [];
57
    private bool $headerTableEnabled = true;
58
    private array $headerRowAttributes = [];
59
    private array $rowAttributes = [];
60
    private array $tableAttributes = [];
61
    private array $tbodyAttributes = [];
62
    private array $headerCellAttributes = [];
63
    private array $bodyCellAttributes = [];
64
65 88
    public function __construct(
66
        private ContainerInterface $columnRenderersContainer,
67
        TranslatorInterface|null $translator = null,
68
        UrlGeneratorInterface|null $urlGenerator = null
69
    ) {
70 88
        parent::__construct($translator, $urlGenerator);
71
    }
72
73
    /**
74
     * Returns a new instance with anonymous function that is called once AFTER rendering each data.
75
     *
76
     * @param Closure|null $value The anonymous function that is called once AFTER rendering each data.
77
     */
78 2
    public function afterRow(Closure|null $value): self
79
    {
80 2
        $new = clone $this;
81 2
        $new->afterRow = $value;
82
83 2
        return $new;
84
    }
85
86
    /**
87
     * Return a new instance with anonymous function that is called once BEFORE rendering each data.
88
     *
89
     * @param Closure|null $value The anonymous function that is called once BEFORE rendering each data.
90
     */
91 2
    public function beforeRow(Closure|null $value): self
92
    {
93 2
        $new = clone $this;
94 2
        $new->beforeRow = $value;
95
96 2
        return $new;
97
    }
98
99
    /**
100
     * Return a new instance the specified columns.
101
     *
102
     * @param ColumnInterface ...$values The grid column configuration. Each array element represents the configuration
103
     * for one particular grid column. For example,
104
     *
105
     * ```php
106
     * [
107
     *     SerialColumn::create(),
108
     *     DetailColumn::create()
109
     *         ->attribute('identity_id')
110
     *         ->filterAttribute('identity_id')
111
     *         ->filterValueDefault(0)
112
     *         ->filterAttributes(['class' => 'text-center', 'maxlength' => '5', 'style' => 'width:60px']),
113
     *     ActionColumn::create()->primaryKey('identity_id')->visibleButtons(['view' => true]),
114
     * ]
115
     * ```
116
     */
117 82
    public function columns(ColumnInterface ...$values): self
118
    {
119 82
        $new = clone $this;
120 82
        $new->columns = $values;
121 82
        return $new;
122
    }
123
124
    /**
125
     * Returns a new instance with the specified column group enabled.
126
     *
127
     * @param bool $value Whether to enable the column group.
128
     */
129 3
    public function columnsGroupEnabled(bool $value): self
130
    {
131 3
        $new = clone $this;
132 3
        $new->columnsGroupEnabled = $value;
133
134 3
        return $new;
135
    }
136
137
    /**
138
     * Return new instance with the HTML display when the content is empty.
139
     *
140
     * @param string $value The HTML display when the content of a cell is empty. This property is used to render cells
141
     * that have no defined content, e.g. empty footer or filter cells.
142
     */
143 2
    public function emptyCell(string $value): self
144
    {
145 2
        $new = clone $this;
146 2
        $new->emptyCell = $value;
147
148 2
        return $new;
149
    }
150
151
    /**
152
     * Return new instance with the filter model name.
153
     *
154
     * @param string|null $value The form model name that keeps the user-entered filter data. When this property is set, the
155
     * grid view will enable column-based filtering. Each data column by default will display a text field at the top
156
     * that users can fill in to filter the data.
157
     *
158
     * Note that in order to show an input field for filtering, a column must have its {@see DataColumn::attribute}
159
     * property set and the attribute should be active in the current scenario of $filterModelName or have
160
     * {@see DataColumn::filter} set as the HTML code for the input field.
161
     */
162 11
    public function filterModelName(?string $value): self
163
    {
164 11
        $new = clone $this;
165 11
        $new->filterModelName = $value;
166
167 11
        return $new;
168
    }
169
170
    /**
171
     * Return new instance with the filter position.
172
     *
173
     * @param string $filterPosition Whether the filters should be displayed in the grid view. Valid values include:
174
     *
175
     * - {@see FILTER_POS_HEADER}: The filters will be displayed on top of each column's header cell.
176
     * - {@see FILTER_POS_BODY}: The filters will be displayed right below each column's header cell.
177
     * - {@see FILTER_POS_FOOTER}: The filters will be displayed below each column's footer cell.
178
     */
179 3
    public function filterPosition(string $filterPosition): self
180
    {
181 3
        $new = clone $this;
182 3
        $new->filterPosition = $filterPosition;
183
184 3
        return $new;
185
    }
186
187
    /**
188
     * Returns a new instance with the HTML attributes for filter row.
189
     *
190
     * @param array $values Attribute values indexed by attribute names.
191
     */
192 7
    public function filterRowAttributes(array $values): self
193
    {
194 7
        $new = clone $this;
195 7
        $new->filterRowAttributes = $values;
196
197 7
        return $new;
198
    }
199
200
    /**
201
     * Return new instance whether to show the footer section of the grid.
202
     *
203
     * @param bool $value Whether to show the footer section of the grid.
204
     */
205 4
    public function footerEnabled(bool $value): self
206
    {
207 4
        $new = clone $this;
208 4
        $new->footerEnabled = $value;
209
210 4
        return $new;
211
    }
212
213
    /**
214
     * Returns a new instance with the HTML attributes for footer row.
215
     *
216
     * @param array $values Attribute values indexed by attribute names.
217
     */
218 2
    public function footerRowAttributes(array $values): self
219
    {
220 2
        $new = clone $this;
221 2
        $new->footerRowAttributes = $values;
222
223 2
        return $new;
224
    }
225
226
    /**
227
     * Return new instance whether to show the header table section of the grid.
228
     *
229
     * @param bool $value Whether to show the header table section of the grid.
230
     */
231 2
    public function headerTableEnabled(bool $value): self
232
    {
233 2
        $new = clone $this;
234 2
        $new->headerTableEnabled = $value;
235
236 2
        return $new;
237
    }
238
239
    /**
240
     * Return new instance with the HTML attributes for the header row.
241
     *
242
     * @param array $values Attribute values indexed by attribute names.
243
     */
244 2
    public function headerRowAttributes(array $values): self
245
    {
246 2
        $new = clone $this;
247 2
        $new->headerRowAttributes = $values;
248
249 2
        return $new;
250
    }
251
252
    /**
253
     * Return new instance with the HTML attributes for row of the grid.
254
     *
255
     * @param array $values Attribute values indexed by attribute names.
256
     *
257
     * This can be either an array specifying the common HTML attributes for all body rows.
258
     */
259 2
    public function rowAttributes(array $values): self
260
    {
261 2
        $new = clone $this;
262 2
        $new->rowAttributes = $values;
263
264 2
        return $new;
265
    }
266
267
    /**
268
     * Return new instance with the HTML attributes for the `table` tag.
269
     *
270
     * @param array $attributes The tag attributes in terms of name-value pairs.
271
     */
272 2
    public function tableAttributes(array $attributes): self
273
    {
274 2
        $new = clone $this;
275 2
        $new->tableAttributes = $attributes;
276 2
        return $new;
277
    }
278
279
    /**
280
     * Add one or more CSS classes to the `table` tag.
281
     *
282
     * @param string|null ...$class One or many CSS classes.
283
     */
284
    public function addTableClass(?string ...$class): self
285
    {
286
        $new = clone $this;
287
        Html::addCssClass($new->tableAttributes, $class);
288
        return $new;
289
    }
290
291
    /**
292
     * Replace current `table` tag CSS classes with a new set of classes.
293
     *
294
     * @param string|null ...$class One or many CSS classes.
295
     */
296
    public function tableClass(?string ...$class): static
297
    {
298
        $new = clone $this;
299
        $new->tableAttributes['class'] = array_filter($class, static fn ($c) => $c !== null);
300
        return $new;
301
    }
302
303
    /**
304
     * Return new instance with the HTML attributes for the `tbody` tag.
305
     *
306
     * @param array $attributes The tag attributes in terms of name-value pairs.
307
     */
308
    public function tbodyAttributes(array $attributes): self
309
    {
310
        $new = clone $this;
311
        $new->tbodyAttributes = $attributes;
312
        return $new;
313
    }
314
315
    /**
316
     * Add one or more CSS classes to the `tbody` tag.
317
     *
318
     * @param string|null ...$class One or many CSS classes.
319
     */
320
    public function addTbodyClass(?string ...$class): self
321
    {
322
        $new = clone $this;
323
        Html::addCssClass($new->tbodyAttributes, $class);
324
        return $new;
325
    }
326
327
    /**
328
     * Replace current `tbody` tag CSS classes with a new set of classes.
329
     *
330
     * @param string|null ...$class One or many CSS classes.
331
     */
332
    public function tbodyClass(?string ...$class): static
333
    {
334
        $new = clone $this;
335
        $new->tbodyAttributes['class'] = array_filter($class, static fn ($c) => $c !== null);
336
        return $new;
337
    }
338
339
    /**
340
     * Return new instance with the HTML attributes for the `th` tag.
341
     *
342
     * @param array $attributes The tag attributes in terms of name-value pairs.
343
     */
344
    public function headerCellAttributes(array $attributes): self
345
    {
346
        $new = clone $this;
347
        $new->headerCellAttributes = $attributes;
348
        return $new;
349
    }
350
351
    /**
352
     * Return new instance with the HTML attributes for the `td` tag.
353
     *
354
     * @param array $attributes The tag attributes in terms of name-value pairs.
355
     */
356
    public function bodyCellAttributes(array $attributes): self
357
    {
358
        $new = clone $this;
359
        $new->bodyCellAttributes = $attributes;
360
        return $new;
361
    }
362
363
    /**
364
     * Renders the data active record classes for the grid view.
365
     *
366
     * @throws InvalidConfigException
367
     * @throws NotFoundException
368
     * @throws NotInstantiableException
369
     * @throws CircularReferenceException
370
     */
371 84
    protected function renderItems(): string
372
    {
373 84
        $columns = empty($this->columns) ? $this->guessColumns() : $this->columns;
374 84
        $columns = array_filter(
375 84
            $columns,
376 84
            static fn(ColumnInterface $column) => $column->isVisible()
377 84
        );
378
379 84
        $renderers = [];
380 84
        foreach ($columns as $i => $column) {
381 81
            $renderers[$i] = $this->getColumnRenderer($column);
382
        }
383
384 84
        $blocks = [];
385
386 84
        $globalContext = new GlobalContext(
387 84
            $this->getDataReader(),
388 84
            $this->sortLinkAttributes,
389 84
            $this->urlArguments,
390 84
            $this->urlQueryParameters,
391 84
            $this->filterModelName,
392 84
        );
393
394 84
        if ($this->columnsGroupEnabled) {
395 2
            $tags = [];
396 2
            foreach ($columns as $i => $column) {
397 2
                $cell = $renderers[$i]->renderColumn($column, new Cell(), $globalContext);
398 2
                $tags[] = Html::col($cell->getAttributes());
399
            }
400 2
            $blocks[] = Html::colgroup()->columns(...$tags)->render();
401
        }
402
403 84
        if ($this->filterPosition === self::FILTER_POS_BODY
404 2
            || $this->filterPosition === self::FILTER_POS_HEADER
405 84
            || $this->filterPosition === self::FILTER_POS_FOOTER
406
        ) {
407 84
            $tags = [];
408 84
            $hasFilters = false;
409 84
            foreach ($columns as $i => $column) {
410 81
                $baseCell = new Cell(encode: false, content: '&nbsp;');
411 81
                $cell = $renderers[$i]->renderFilter($column, $baseCell, $globalContext);
412 81
                if ($cell === null) {
413 75
                    $cell = $baseCell;
414
                } else {
415 18
                    $hasFilters = true;
416
                }
417
                /** @var string|Stringable $content */
418 81
                $content = $cell->getContent();
419 81
                $tags[] = Html::td(attributes: $cell->getAttributes())
420 81
                    ->content($content)
421 81
                    ->encode($cell->isEncode())
422 81
                    ->doubleEncode($cell->isDoubleEncode());
423
            }
424 84
            $filterRow = $hasFilters ? Html::tr($this->filterRowAttributes)->cells(...$tags) : null;
0 ignored issues
show
The condition $hasFilters is always false.
Loading history...
425
        } else {
426
            $filterRow = null;
427
        }
428
429 84
        if ($this->headerTableEnabled) {
430 83
            $tags = [];
431 83
            foreach ($columns as $i => $column) {
432 80
                $cell = $renderers[$i]->renderHeader($column, new Cell($this->headerCellAttributes), $globalContext);
433
                /** @var string|Stringable $content */
434 80
                $content = $cell?->getContent();
435 80
                $tags[] = $cell === null
436 5
                    ? Html::th('&nbsp;')->encode(false)
437 80
                    : Html::th(attributes: $cell->getAttributes())
438 80
                        ->content($content)
439 80
                        ->encode($cell->isEncode())
440 80
                        ->doubleEncode($cell->isDoubleEncode());
441
            }
442 83
            $headerRow = Html::tr($this->headerRowAttributes)->cells(...$tags);
443
444 83
            if ($filterRow === null) {
0 ignored issues
show
The condition $filterRow === null is always true.
Loading history...
445 65
                $rows = [$headerRow];
446 18
            } elseif ($this->filterPosition === self::FILTER_POS_HEADER) {
447 1
                $rows = [$filterRow, $headerRow];
448 17
            } elseif ($this->filterPosition === self::FILTER_POS_BODY) {
449 16
                $rows = [$headerRow, $filterRow];
450
            } else {
451 1
                $rows = [$headerRow];
452
            }
453
454 83
            $blocks[] = Html::thead()->rows(...$rows)->render();
455
        }
456
457 84
        if ($this->footerEnabled) {
458 3
            $tags = [];
459 3
            foreach ($columns as $i => $column) {
460 3
                $cell = $renderers[$i]->renderFooter(
461 3
                    $column,
462 3
                    (new Cell())->content('&nbsp;')->encode(false),
463 3
                    $globalContext
464 3
                );
465
                /** @var string|Stringable $content */
466 3
                $content = $cell->getContent();
467 3
                $tags[] = Html::td(attributes: $cell->getAttributes())
468 3
                    ->content($content)
469 3
                    ->encode($cell->isEncode())
470 3
                    ->doubleEncode($cell->isDoubleEncode());
471
            }
472 3
            $footerRow = Html::tr($this->footerRowAttributes)->cells(...$tags);
473
474 3
            $rows = [$footerRow];
475 3
            if ($this->filterPosition === self::FILTER_POS_FOOTER) {
476
                /** @var Tr */
477 1
                $rows[] = $filterRow;
478
            }
479
480 3
            $blocks[] = Html::tfoot()->rows(...$rows)->render();
481
        }
482
483 84
        $rows = [];
484 84
        $index = 0;
485 84
        foreach ($this->getItems() as $key => $value) {
486 77
            if ($this->beforeRow !== null) {
487
                /** @var Tr|null $row */
488 1
                $row = call_user_func($this->beforeRow, $value, $key, $index, $this);
489 1
                if (!empty($row)) {
490 1
                    $rows[] = $row;
491
                }
492
            }
493
494 77
            $tags = [];
495 77
            foreach ($columns as $i => $column) {
496 76
                $context = new DataContext($column, $value, $key, $index);
497 76
                $cell = $renderers[$i]->renderBody($column, new Cell(), $context);
498 76
                $contentSource = $cell->getContent();
499
                /** @var string|Stringable $content */
500 76
                $content = $contentSource instanceof Closure
501
                    ? $contentSource($context)
502 76
                    : $contentSource;
503 76
                $tags[] = empty($content)
504 2
                    ? Html::td()->content($this->emptyCell)->encode(false)
505 75
                    : Html::td(attributes: $this->prepareBodyAttributes($cell->getAttributes(), $context))
506 75
                        ->content($content)
507 75
                        ->encode($cell->isEncode())
508 75
                        ->doubleEncode($cell->isDoubleEncode());
509
            }
510 77
            $rows[] = Html::tr($this->rowAttributes)->cells(...$tags);
511
512 77
            if ($this->afterRow !== null) {
513
                /** @var Tr|null $row */
514 1
                $row = call_user_func($this->afterRow, $value, $key, $index, $this);
515 1
                if (!empty($row)) {
516 1
                    $rows[] = $row;
517
                }
518
            }
519
520 77
            $index++;
521
        }
522 84
        $blocks[] = empty($rows)
523 7
            ? Html::tbody($this->tbodyAttributes)
524 7
                ->rows(Html::tr()->cells($this->renderEmpty(count($columns))))
525 7
                ->render()
526 77
            : Html::tbody($this->tbodyAttributes)->rows(...$rows)->render();
527
528 84
        return Html::tag('table', attributes: $this->tableAttributes)->open()
529 84
            . PHP_EOL
530 84
            . implode(PHP_EOL, $blocks)
531 84
            . PHP_EOL
532 84
            . '</table>';
533
    }
534
535
    /**
536
     * This function tries to guess the columns to show from the given data if {@see columns} are not explicitly
537
     * specified.
538
     *
539
     * @psalm-return list<ColumnInterface>
540
     */
541 3
    private function guessColumns(): array
542
    {
543 3
        $items = $this->getItems();
544
545 3
        $columns = [];
546 3
        foreach ($items as $item) {
547
            /**
548
             * @var string $name
549
             * @var mixed $value
550
             */
551 1
            foreach ($item as $name => $value) {
552 1
                if ($value === null || is_scalar($value) || is_callable([$value, '__toString'])) {
553 1
                    $columns[] = new DataColumn(property: $name);
554
                }
555
            }
556 1
            break;
557
        }
558
559 3
        if (!empty($items)) {
560 1
            $columns[] = new ActionColumn();
561
        }
562
563 3
        return $columns;
564
    }
565
566 75
    private function prepareBodyAttributes(array $attributes, DataContext $context): array
567
    {
568 75
        foreach ($attributes as $i => $attribute) {
569 5
            if (is_callable($attribute)) {
570 1
                $attributes[$i] = $attribute($context);
571
            }
572
        }
573
574 75
        return $attributes;
575
    }
576
577 81
    private function getColumnRenderer(ColumnInterface $column): ColumnRendererInterface
578
    {
579
        /** @var ColumnRendererInterface */
580 81
        return $this->columnRenderersContainer->get($column->getRenderer());
581
    }
582
}
583