feat: Add RepeatableEntry table layout and TableColumn component (#17865)

* Add RepeatableEntry table layout and TableColumn component with tests

* chore: fix code style

* consistency with repeater

* Update EntriesDemo.php

---------

Co-authored-by: oddvalue <oddvalue@users.noreply.github.com>
Co-authored-by: Dan Harrin <git@danharrin.com>
This commit is contained in:
oddvalue
2025-09-25 10:03:43 +01:00
committed by GitHub
co-authored by oddvalue Dan Harrin
parent 73fd07ee07
commit 7c5cd978d7
19 changed files with 439 additions and 28 deletions
@@ -9,6 +9,7 @@ use Filament\Infolists\Components\IconEntry;
use Filament\Infolists\Components\ImageEntry;
use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\RepeatableEntry\TableColumn;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Flex;
use Filament\Schemas\Components\Group;
@@ -714,6 +715,25 @@ class EntriesDemo extends Component implements HasSchemas
->columns(2)
->grid(2),
]),
Group::make()
->id('repeatableTable')
->extraAttributes([
'class' => 'p-16 max-w-5xl',
])
->schema([
RepeatableEntry::make('comments')
->table([
TableColumn::make('Author'),
TableColumn::make('Title'),
TableColumn::make('Published'),
])
->schema([
TextEntry::make('author'),
TextEntry::make('title'),
IconEntry::make('is_published')
->boolean(),
]),
]),
Group::make()
->id('suffixAction')
->extraAttributes([
@@ -736,11 +756,13 @@ class EntriesDemo extends Component implements HasSchemas
'author' => ['name' => 'Jane Doe'],
'title' => 'Wow!',
'content' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam euismod, nisl eget aliquam ultricies, nunc nisl aliquet nunc, quis aliquam nisl.',
'is_published' => true,
],
[
'author' => ['name' => 'John Doe'],
'title' => 'This isn\'t working. Help!',
'content' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam euismod, nisl eget aliquam ultricies, nunc nisl aliquet nunc, quis aliquam nisl.',
'is_published' => false,
],
],
]);
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+9
View File
@@ -2228,6 +2228,15 @@ export default {
deviceScaleFactor: 3,
},
},
'infolists/entries/repeatable/table': {
url: 'infolists/entries',
selector: '#repeatableTable',
viewport: {
width: 1920,
height: 640,
deviceScaleFactor: 3,
},
},
'infolists/entries/actions/suffix': {
url: 'infolists/entries',
selector: '#suffixAction',
@@ -158,24 +158,22 @@
@endphp
@if ($schemaComponent->isVisible())
<td
@if (! (($schemaComponent instanceof Action) || ($schemaComponent instanceof ActionGroup)))
@php
$schemaComponentStatePath = $schemaComponent->getStatePath();
@endphp
@php
$schemaComponentStatePath = $schemaComponent->getStatePath();
@endphp
x-data="filamentSchemaComponent({
path: @js($schemaComponentStatePath),
containerPath: @js($itemStatePath),
isLive: @js($schemaComponent->isLive()),
$wire,
})"
@if ($afterStateUpdatedJs = $schemaComponent->getAfterStateUpdatedJs())
x-init="{{ implode(';', array_map(
fn (string $js): string => '$wire.watch(' . Js::from($schemaComponentStatePath) . ', ($state, $old) => ($state !== undefined) && eval(' . Js::from($js) . '))',
$afterStateUpdatedJs,
)) }}"
@endif
<td
x-data="filamentSchemaComponent({
path: @js($schemaComponentStatePath),
containerPath: @js($itemStatePath),
isLive: @js($schemaComponent->isLive()),
$wire,
})"
@if ($afterStateUpdatedJs = $schemaComponent->getAfterStateUpdatedJs())
x-init="{{ implode(';', array_map(
fn (string $js): string => '$wire.watch(' . Js::from($schemaComponentStatePath) . ', ($state, $old) => ($state !== undefined) && eval(' . Js::from($js) . '))',
$afterStateUpdatedJs,
)) }}"
@endif
>
{{ $schemaComponent }}
@@ -80,3 +80,66 @@ RepeatableEntry::make('comments')
```
<UtilityInjection set="infolistEntries" version="4.x">As well as allowing a static value, the `contained()` method also accepts a function to dynamically calculate it. You can inject various utilities into the function as parameters.</UtilityInjection>
## Table repeatable layout
You can present repeatable items in a table format using the `table()` method, which accepts an array of `TableColumn` objects. These objects represent the columns of the table, which correspond to any components in the schema of the entry:
```php
use Filament\Infolists\Components\IconEntry;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\RepeatableEntry\TableColumn;
use Filament\Infolists\Components\TextEntry;
RepeatableEntry::make('comments')
->table([
TableColumn::make('Author'),
TableColumn::make('Title'),
TableColumn::make('Published'),
])
->schema([
TextEntry::make('author.name'),
TextEntry::make('title'),
IconEntry::make('is_published')
->boolean(),
])
```
<AutoScreenshot name="infolists/entries/repeatable/table" alt="Repeatable entry with table layout" version="4.x" />
The labels displayed in the header of the table are passed to the `TableColumn::make()` method. If you want to provide an accessible label for a column but do not wish to display it, you can use the `hiddenHeaderLabel()` method:
```php
use Filament\Infolists\Components\RepeatableEntry\TableColumn;
TableColumn::make('Name')
->hiddenHeaderLabel()
```
You can enable wrapping of the column header using the `wrapHeader()` method:
```php
use Filament\Infolists\Components\RepeatableEntry\TableColumn;
TableColumn::make('Name')
->wrapHeader()
```
You can also adjust the alignment of the column header using the `alignment()` method, passing an `Alignment` option of `Alignment::Start`, `Alignment::Center`, or `Alignment::End`:
```php
use Filament\Infolists\Components\RepeatableEntry\TableColumn;
use Filament\Support\Enums\Alignment;
TableColumn::make('Name')
->alignment(Alignment::Start)
```
You can set a fixed column width using the `width()` method, passing a string value that represents the width of the column. This value is passed directly to the `style` attribute of the column header:
```php
use Filament\Infolists\Components\RepeatableEntry\TableColumn;
TableColumn::make('Name')
->width('200px')
```
@@ -13,3 +13,132 @@
}
}
}
.fi-in-table-repeatable {
@apply grid gap-3;
& > table {
@apply block w-full divide-y divide-gray-200 rounded-xl bg-white shadow-sm ring-1 ring-gray-950/5 dark:divide-white/10 dark:bg-gray-900 dark:ring-white/10;
& > thead {
@apply hidden whitespace-nowrap;
& > tr {
& > th {
@apply border-gray-200 bg-gray-50 px-3 py-2 text-start text-sm font-semibold text-gray-950 first-of-type:rounded-tl-xl last-of-type:rounded-tr-xl dark:border-white/5 dark:bg-white/5 dark:text-white [&:not(:first-of-type)]:border-s [&:not(:last-of-type)]:border-e;
&.fi-align-center {
@apply text-center;
}
&.fi-align-end,
&.fi-align-right {
@apply text-end;
}
&.fi-wrapped {
@apply whitespace-normal;
}
&:not(.fi-wrapped) {
@apply whitespace-nowrap;
}
&.fi-in-table-repeatable-empty-header-cell {
@apply w-1;
}
}
}
}
& > tbody {
@apply block divide-y divide-gray-200 dark:divide-white/5;
& > tr {
@apply grid gap-6 p-6;
& > td {
@apply block;
&.fi-hidden {
@apply hidden;
}
}
}
}
}
@supports (container-type: inline-size) {
@apply @container;
& > table {
@apply @xl:table;
& > thead {
@apply @xl:table-header-group;
}
& > tbody {
@apply @xl:table-row-group;
& > tr {
@apply @xl:table-row @xl:p-0;
& > td {
@apply @xl:table-cell @xl:px-3 @xl:py-2;
&.fi-hidden {
@apply @xl:table-cell;
}
& .fi-in-entry {
@apply @xl:gap-y-0;
}
& .fi-in-entry-label {
@apply @xl:hidden;
}
}
}
}
& .fi-in-table-repeatable-actions {
@apply @xl:px-3 @xl:py-2;
}
}
}
@supports not (container-type: inline-size) {
& > table {
@apply lg:table;
& > thead {
@apply lg:table-header-group;
}
& > tbody {
@apply lg:table-row-group;
& > tr {
@apply lg:table-row lg:p-0;
& > td {
@apply lg:table-cell lg:px-3 lg:py-2;
&.fi-hidden {
@apply lg:table-cell;
}
& .fi-in-entry {
@apply lg:gap-y-0;
}
& .fi-in-entry-label {
@apply lg:hidden;
}
}
}
}
}
}
}
@@ -81,7 +81,7 @@ class CodeEntry extends Entry implements HasEmbeddedView
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php if (filled($placeholder !== null)) { ?>
<?php if (filled($placeholder)) { ?>
<p class="fi-in-placeholder">
<?= e($placeholder) ?>
</p>
@@ -45,7 +45,7 @@ class ColorEntry extends Entry implements HasEmbeddedView
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php if (filled($placeholder !== null)) { ?>
<?php if (filled($placeholder)) { ?>
<p class="fi-in-placeholder">
<?= e($placeholder) ?>
</p>
@@ -259,7 +259,7 @@ class IconEntry extends Entry implements HasEmbeddedView
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php if (filled($placeholder !== null)) { ?>
<?php if (filled($placeholder)) { ?>
<p class="fi-in-placeholder">
<?= e($placeholder) ?>
</p>
@@ -448,7 +448,7 @@ class ImageEntry extends Entry implements HasEmbeddedView
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php if (filled($placeholder !== null)) { ?>
<?php if (filled($placeholder)) { ?>
<p class="fi-in-placeholder">
<?= e($placeholder) ?>
</p>
@@ -2,11 +2,17 @@
namespace Filament\Infolists\Components;
use Closure;
use Exception;
use Filament\Infolists\Components\RepeatableEntry\TableColumn;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Concerns\HasContainerGridLayout;
use Filament\Schemas\Schema;
use Filament\Support\Components\Contracts\HasEmbeddedView;
use Filament\Support\Concerns\CanBeContained;
use Filament\Support\Enums\Alignment;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Js;
class RepeatableEntry extends Entry implements HasEmbeddedView
@@ -14,6 +20,41 @@ class RepeatableEntry extends Entry implements HasEmbeddedView
use CanBeContained;
use HasContainerGridLayout;
/**
* @var array<TableColumn> | Closure | null
*/
protected array | Closure | null $tableColumns = null;
/**
* Configure table columns for display
*
* @param array<TableColumn> | Closure | null $columns
*/
public function table(array | Closure | null $columns): static
{
$this->tableColumns = $columns;
return $this;
}
/**
* Get configured table columns
*
* @return ?array<TableColumn>
*/
public function getTableColumns(): ?array
{
return $this->evaluate($this->tableColumns);
}
/**
* Determine if component should render as table
*/
public function isTable(): bool
{
return filled($this->getTableColumns());
}
/**
* @return array<Schema>
*/
@@ -50,6 +91,10 @@ class RepeatableEntry extends Entry implements HasEmbeddedView
public function toEmbeddedHtml(): string
{
if ($this->isTable()) {
return $this->toEmbeddedTableHtml();
}
$items = $this->getItems();
$attributes = $this->getExtraAttributeBag()
@@ -74,7 +119,7 @@ class RepeatableEntry extends Entry implements HasEmbeddedView
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php if (filled($placeholder !== null)) { ?>
<?php if (filled($placeholder)) { ?>
<p class="fi-in-placeholder">
<?= e($placeholder) ?>
</p>
@@ -98,4 +143,100 @@ class RepeatableEntry extends Entry implements HasEmbeddedView
<?php return $this->wrapEmbeddedHtml(ob_get_clean());
}
protected function toEmbeddedTableHtml(): string
{
$items = $this->getItems();
$tableColumns = $this->getTableColumns();
$attributes = $this->getExtraAttributeBag()
->class([
'fi-in-table-repeatable',
]);
if (empty($items)) {
$attributes = $attributes
->merge([
'x-tooltip' => filled($tooltip = $this->getEmptyTooltip())
? '{
content: ' . Js::from($tooltip) . ',
theme: $store.theme,
}'
: null,
], escape: false);
$placeholder = $this->getPlaceholder();
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php if (filled($placeholder)) { ?>
<p class="fi-in-placeholder">
<?= e($placeholder) ?>
</p>
<?php } ?>
</div>
<?php return $this->wrapEmbeddedHtml(ob_get_clean());
}
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<table>
<thead>
<tr>
<?php foreach ($tableColumns as $column) { ?>
<th
class="<?= Arr::toCssClasses([
'fi-wrapped' => $column->canHeaderWrap(),
(($columnAlignment = $column->getAlignment()) instanceof Alignment) ? ('fi-align-' . $columnAlignment->value) : $columnAlignment,
]) ?>"
<?php if (filled($columnWidth = $column->getWidth())) { ?>
style="width: <?= $columnWidth ?>"
<?php } ?>
>
<?php if (! $column->isHeaderLabelHidden()) { ?>
<?= e($column->getLabel()) ?>
<?php } else { ?>
<span class="fi-sr-only">
<?= e($column->getLabel()) ?>
</span>
<?php } ?>
</th>
<?php } ?>
</tr>
</thead>
<tbody>
<?php foreach ($items as $item) { ?>
<tr>
<?php $counter = 0 ?>
<?php foreach ($item->getComponents(withHidden: true) as $component) { ?>
<?php throw_unless(
$component instanceof Component,
new Exception('Table repeatable entries must only contain schema components, but [' . $component::class . '] was used.'),
) ?>
<?php if (count($tableColumns) > $counter) { ?>
<?php $counter++ ?>
<?php if ($component->isVisible()) { ?>
<td>
<?= $component->toHtml() ?>
</td>
<?php } else { ?>
<td class="fi-hidden"></td>
<?php } ?>
<?php } ?>
<?php } ?>
</tr>
<?php } ?>
</tbody>
</table>
</div>
<?php return $this->wrapEmbeddedHtml(ob_get_clean());
}
}
@@ -0,0 +1,49 @@
<?php
namespace Filament\Infolists\Components\RepeatableEntry;
use Closure;
use Filament\Support\Components\Component;
use Filament\Support\Concerns\CanWrapHeader;
use Filament\Support\Concerns\HasAlignment;
use Filament\Support\Concerns\HasWidth;
use Illuminate\Contracts\Support\Htmlable;
class TableColumn extends Component
{
use CanWrapHeader;
use HasAlignment;
use HasWidth;
protected string $evaluationIdentifier = 'column';
protected bool | Closure $isHeaderLabelHidden = false;
public function __construct(protected string | Htmlable | Closure $label) {}
public static function make(string | Htmlable | Closure $label): static
{
$static = app(static::class, ['label' => $label]);
$static->configure();
return $static;
}
public function hiddenHeaderLabel(bool | Closure $condition = true): static
{
$this->isHeaderLabelHidden = $condition;
return $this;
}
public function getLabel(): string | Htmlable
{
return $this->evaluate($this->label);
}
public function isHeaderLabelHidden(): bool
{
return (bool) $this->evaluate($this->isHeaderLabelHidden);
}
}
@@ -189,7 +189,7 @@ class TextEntry extends Entry implements HasAffixActions, HasEmbeddedView
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php if (filled($placeholder !== null)) { ?>
<?php if (filled($placeholder)) { ?>
<p class="fi-in-placeholder">
<?= e($placeholder) ?>
</p>
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -48,7 +48,7 @@ class ColorColumn extends Column implements HasEmbeddedView
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php if (filled($placeholder !== null)) { ?>
<?php if (filled($placeholder)) { ?>
<p class="fi-ta-placeholder">
<?= e($placeholder) ?>
</p>
+1 -1
View File
@@ -290,7 +290,7 @@ class IconColumn extends Column implements HasEmbeddedView
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php if (filled($placeholder !== null)) { ?>
<?php if (filled($placeholder)) { ?>
<p class="fi-ta-placeholder">
<?= e($placeholder) ?>
</p>
+1 -1
View File
@@ -443,7 +443,7 @@ class ImageColumn extends Column implements HasEmbeddedView
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php if (filled($placeholder !== null)) { ?>
<?php if (filled($placeholder)) { ?>
<p class="fi-ta-placeholder">
<?= e($placeholder) ?>
</p>
+1 -1
View File
@@ -198,7 +198,7 @@ class TextColumn extends Column implements HasEmbeddedView
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php if (filled($placeholder !== null)) { ?>
<?php if (filled($placeholder)) { ?>
<p class="fi-ta-placeholder">
<?= e($placeholder) ?>
</p>