🆙 Add cms i using 🆙

This commit is contained in:
Remco
2025-11-25 22:42:56 +01:00
parent 94704e0925
commit d44196149e
35591 changed files with 3601123 additions and 0 deletions
@@ -0,0 +1,9 @@
<?php
namespace Filament\Tables\Actions;
enum HeaderActionsPosition
{
case Adaptive;
case Bottom;
}
@@ -0,0 +1,14 @@
<?php
namespace Filament\Tables\Columns;
/**
* @deprecated Use `TextColumn` with the `badge()` method instead.
*/
class BadgeColumn extends TextColumn
{
public function isBadge(): bool
{
return true;
}
}
@@ -0,0 +1,14 @@
<?php
namespace Filament\Tables\Columns;
/**
* @deprecated Use `IconColumn` with the `boolean()` method instead.
*/
class BooleanColumn extends IconColumn
{
public function isBoolean(): bool
{
return true;
}
}
@@ -0,0 +1,98 @@
<?php
namespace Filament\Tables\Columns;
use Filament\Forms\Components\Concerns\HasExtraInputAttributes;
use Filament\Support\Components\Contracts\HasEmbeddedView;
use Filament\Support\Enums\Alignment;
use Filament\Support\Facades\FilamentAsset;
use Filament\Tables\Columns\Contracts\Editable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Js;
class CheckboxColumn extends Column implements Editable, HasEmbeddedView
{
use Concerns\CanBeValidated;
use Concerns\CanUpdateState;
use HasExtraInputAttributes;
protected function setUp(): void
{
parent::setUp();
$this->disabledClick();
$this->rules(['boolean']);
}
public function toEmbeddedHtml(): string
{
$isDisabled = $this->isDisabled();
$state = (bool) $this->getState();
$attributes = $this->getExtraAttributeBag()
->merge([
'x-load' => true,
'x-load-src' => FilamentAsset::getAlpineComponentSrc('columns/checkbox', 'filament/tables'),
'x-data' => 'checkboxTableColumn({
name: ' . Js::from($this->getName()) . ',
recordKey: ' . Js::from($this->getRecordKey()) . ',
state: ' . Js::from($state) . ',
})',
], escape: false)
->class([
'fi-ta-checkbox',
((($alignment = $this->getAlignment()) instanceof Alignment) ? "fi-align-{$alignment->value}" : (is_string($alignment) ? $alignment : '')),
'fi-inline' => $this->isInline(),
]);
$inputAttributes = $this->getExtraInputAttributeBag()
->merge([
'disabled' => $isDisabled,
'wire:loading.attr' => 'disabled',
'wire:target' => implode(',', Table::LOADING_TARGETS),
'x-bind:disabled' => $isDisabled ? null : 'isLoading',
'x-tooltip' => filled($tooltip = $this->getTooltip($state))
? '{
content: ' . Js::from($tooltip) . ',
theme: $store.theme,
allowHTML: ' . Js::from($tooltip instanceof Htmlable) . ',
}'
: null,
], escape: false)
->class([
'fi-checkbox-input',
]);
ob_start(); ?>
<div
x-on:click.stop
wire:ignore.self
<?= $attributes->toHtml() ?>
>
<input type="hidden" value="<?= $state ? 1 : 0 ?>" x-ref="serverState" />
<input
type="checkbox"
x-bind:class="{
'fi-valid': ! error,
'fi-invalid': error,
}"
x-model="state"
x-tooltip="
error === undefined
? false
: {
content: error,
theme: $store.theme,
}
"
<?= $inputAttributes->toHtml() ?>
/>
</div>
<?php return ob_get_clean();
}
}
@@ -0,0 +1,121 @@
<?php
namespace Filament\Tables\Columns;
use Filament\Support\Components\Contracts\HasEmbeddedView;
use Filament\Support\Concerns\CanBeCopied;
use Filament\Support\Concerns\CanWrap;
use Filament\Support\Enums\Alignment;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Js;
use Illuminate\View\ComponentAttributeBag;
class ColorColumn extends Column implements HasEmbeddedView
{
use CanBeCopied;
use CanWrap;
public function toEmbeddedHtml(): string
{
$state = $this->getState();
if ($state instanceof Collection) {
$state = $state->all();
}
$alignment = $this->getAlignment();
$attributes = $this->getExtraAttributeBag()
->class([
'fi-ta-color',
'fi-inline' => $this->isInline(),
($alignment instanceof Alignment) ? "fi-align-{$alignment->value}" : (is_string($alignment) ? $alignment : ''),
]);
if (blank($state)) {
$attributes = $attributes
->merge([
'x-tooltip' => filled($tooltip = $this->getEmptyTooltip())
? '{
content: ' . Js::from($tooltip) . ',
theme: $store.theme,
allowHTML: ' . Js::from($tooltip instanceof Htmlable) . ',
}'
: null,
], escape: false);
$placeholder = $this->getPlaceholder();
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php if (filled($placeholder)) { ?>
<p class="fi-ta-placeholder">
<?= e($placeholder) ?>
</p>
<?php } ?>
</div>
<?php return ob_get_clean();
}
$state = Arr::wrap($state);
$attributes = $attributes
->class([
'fi-wrapped' => $this->canWrap(),
]);
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php foreach ($state as $stateItem) { ?>
<?php
$isCopyable = $this->isCopyable($stateItem);
$copyableStateJs = $isCopyable
? Js::from($this->getCopyableState($stateItem) ?? $stateItem)
: null;
$copyMessageJs = $isCopyable
? Js::from($this->getCopyMessage($stateItem))
: null;
$copyMessageDurationJs = $isCopyable
? Js::from($this->getCopyMessageDuration($stateItem))
: null;
?>
<div <?= (new ComponentAttributeBag)
->merge([
'x-on:click.prevent.stop' => $isCopyable
? <<<JS
window.navigator.clipboard.writeText({$copyableStateJs})
\$tooltip({$copyMessageJs}, {
theme: \$store.theme,
timeout: {$copyMessageDurationJs},
})
JS
: null,
'x-tooltip' => filled($tooltip = $this->getTooltip($stateItem))
? '{
content: ' . Js::from($tooltip) . ',
theme: $store.theme,
allowHTML: ' . Js::from($tooltip instanceof Htmlable) . ',
}'
: null,
], escape: false)
->class([
'fi-ta-color-item',
'fi-copyable' => $isCopyable,
])
->style([
'background-color: ' . e($stateItem) => $stateItem,
])
->toHtml() ?>></div>
<?php } ?>
</div>
<?php return ob_get_clean();
}
}
@@ -0,0 +1,201 @@
<?php
namespace Filament\Tables\Columns;
use Filament\Actions\Action;
use Filament\Support\Components\ViewComponent;
use Filament\Support\Concerns\CanAggregateRelatedModels;
use Filament\Support\Concerns\CanGrow;
use Filament\Support\Concerns\CanSpanColumns;
use Filament\Support\Concerns\CanWrapHeader;
use Filament\Support\Concerns\HasAlignment;
use Filament\Support\Concerns\HasCellState;
use Filament\Support\Concerns\HasExtraAttributes;
use Filament\Support\Concerns\HasPlaceholder;
use Filament\Support\Concerns\HasVerticalAlignment;
use Filament\Support\Concerns\HasWidth;
use Filament\Support\Enums\Alignment;
use Filament\Tables\Columns\Concerns\HasTooltip;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\HtmlString;
use Illuminate\View\ComponentAttributeBag;
use LogicException;
use function Filament\Support\generate_href_html;
class Column extends ViewComponent
{
use CanAggregateRelatedModels;
use CanGrow;
use CanSpanColumns;
use CanWrapHeader;
use Concerns\BelongsToGroup;
use Concerns\BelongsToLayout;
use Concerns\BelongsToTable;
use Concerns\CanBeDisabled;
use Concerns\CanBeHidden;
use Concerns\CanBeInline;
use Concerns\CanBeSearchable;
use Concerns\CanBeSortable;
use Concerns\CanBeSummarized;
use Concerns\CanBeToggled;
use Concerns\CanCallAction;
use Concerns\CanOpenUrl;
use Concerns\HasExtraCellAttributes;
use Concerns\HasExtraHeaderAttributes;
use Concerns\HasLabel;
use Concerns\HasName;
use Concerns\HasRecord;
use Concerns\HasRowLoopObject;
use Concerns\InteractsWithTableQuery;
use HasAlignment;
use HasCellState;
use HasExtraAttributes;
use HasPlaceholder;
use HasTooltip;
use HasVerticalAlignment;
use HasWidth;
protected string $evaluationIdentifier = 'column';
protected string $viewIdentifier = 'column';
final public function __construct(string $name)
{
$this->name($name);
}
public static function make(?string $name = null): static
{
$columnClass = static::class;
$name ??= static::getDefaultName();
if (blank($name)) {
throw new LogicException("Column of class [$columnClass] must have a unique name, passed to the [make()] method.");
}
$static = app($columnClass, ['name' => $name]);
$static->configure();
return $static;
}
public static function getDefaultName(): ?string
{
return null;
}
public function getTable(): Table
{
return $this->table ?? $this->getGroup()?->getTable() ?? $this->getLayout()?->getTable() ?? throw new LogicException("The column [{$this->getName()}] is not mounted to a table.");
}
/**
* @return array<mixed>
*/
protected function resolveDefaultClosureDependencyForEvaluationByName(string $parameterName): array
{
return match ($parameterName) {
'livewire' => [$this->getLivewire()],
'record' => [$this->getRecord()],
'rowLoop' => [$this->getRowLoop()],
'state' => [$this->getState()],
'table' => [$this->getTable()],
default => parent::resolveDefaultClosureDependencyForEvaluationByName($parameterName),
};
}
/**
* @return array<mixed>
*/
protected function resolveDefaultClosureDependencyForEvaluationByType(string $parameterType): array
{
$record = $this->getRecord();
if (! $record) {
return parent::resolveDefaultClosureDependencyForEvaluationByType($parameterType);
}
if (! ($record instanceof Model)) {
return parent::resolveDefaultClosureDependencyForEvaluationByType($parameterType);
}
return match ($parameterType) {
Model::class, $record::class => [$record],
default => parent::resolveDefaultClosureDependencyForEvaluationByType($parameterType),
};
}
public function renderInLayout(): ?HtmlString
{
if ($this->isHidden()) {
return null;
}
$attributes = (new ComponentAttributeBag)
->gridColumn(
$this->getColumnSpan(),
$this->getColumnStart(),
)
->class([
'fi-growable' => $this->canGrow(),
(filled($hiddenFrom = $this->getHiddenFrom()) ? "{$hiddenFrom}:fi-hidden" : ''),
(filled($visibleFrom = $this->getVisibleFrom()) ? "{$visibleFrom}:fi-visible" : ''),
]);
$this->inline();
$action = $this->getAction();
$url = $this->getUrl();
$isClickDisabled = $this->isClickDisabled();
$wrapperTag = match (true) {
$url && (! $isClickDisabled) => 'a',
$action && (! $isClickDisabled) => 'button',
default => 'div',
};
$attributes = $attributes
->merge([
'type' => ($wrapperTag === 'button') ? 'button' : null,
'wire:click.prevent.stop' => $wireClickAction = match (true) {
($wrapperTag !== 'button') => null,
$action instanceof Action => "mountTableAction('{$action->getName()}', '{$this->getRecordKey()}')",
filled($action) => "callTableColumnAction('{$this->getName()}', '{$this->getRecordKey()}')",
default => null,
},
'wire:loading.attr' => ($wrapperTag === 'button') ? 'disabled' : null,
'wire:target' => $wireClickAction,
], escape: false)
->class([
'fi-ta-col',
((($alignment = $this->getAlignment()) instanceof Alignment) ? "fi-align-{$alignment->value}" : (is_string($alignment) ? $alignment : '')),
'fi-ta-col-has-column-url' => ($wrapperTag === 'a') && filled($url),
]);
ob_start(); ?>
<<?= $wrapperTag ?>
<?php if ($wrapperTag === 'a') {
echo generate_href_html($url, $this->shouldOpenUrlInNewTab())->toHtml();
} ?>
<?= $attributes->toHtml() ?>
>
<?= $this->toHtml() ?>
</<?= $wrapperTag ?>>
<?php return new HtmlString(ob_get_clean());
}
/**
* @return array<string, mixed>
*/
public function getExtraViewData(): array
{
return [
'record' => $this->getRecord(),
];
}
}
@@ -0,0 +1,103 @@
<?php
namespace Filament\Tables\Columns;
use Closure;
use Filament\Support\Components\Component;
use Filament\Support\Concerns\CanWrapHeader;
use Filament\Support\Concerns\HasAlignment;
use Illuminate\Contracts\Support\Htmlable;
class ColumnGroup extends Component
{
use CanWrapHeader;
use Concerns\BelongsToTable;
use Concerns\CanBeHiddenResponsively;
use Concerns\HasExtraHeaderAttributes;
use HasAlignment;
protected string $evaluationIdentifier = 'group';
protected string | Htmlable | Closure $label;
protected bool $shouldTranslateLabel = false;
/**
* @var array<Column> | Closure
*/
protected array | Closure $columns = [];
/**
* @param array<Column> $columns
*/
final public function __construct(string | Htmlable | Closure $label, array | Closure $columns = [])
{
$this->label($label);
$this->columns($columns);
}
/**
* @param array<Column> $columns
*/
public static function make(string | Htmlable | Closure $label, array | Closure $columns = []): static
{
$static = app(static::class, ['label' => $label, 'columns' => $columns]);
$static->configure();
return $static;
}
public function label(string | Htmlable | Closure $label): static
{
$this->label = $label;
return $this;
}
public function translateLabel(bool $shouldTranslateLabel = true): static
{
$this->shouldTranslateLabel = $shouldTranslateLabel;
return $this;
}
public function getLabel(): string | Htmlable
{
$label = $this->evaluate($this->label);
return $this->shouldTranslateLabel ? __($label) : $label;
}
/**
* @param array<Column> | Closure $columns
*/
public function columns(array | Closure $columns): static
{
$this->columns = $columns;
return $this;
}
/**
* @return array<string, Column>
*/
public function getColumns(): array
{
return array_reduce($this->evaluate($this->columns) ?? [], function (array $result, Column $column): array {
$result[$column->getName()] = $column->group($this);
return $result;
}, []);
}
/**
* @return array<string, Column>
*/
public function getVisibleColumns(): array
{
return array_filter(
$this->getColumns(),
fn (Column $column): bool => $column->isVisible() && (! $column->isToggledHidden()),
);
}
}
@@ -0,0 +1,22 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Filament\Tables\Columns\ColumnGroup;
trait BelongsToGroup
{
protected ?ColumnGroup $group = null;
public function group(?ColumnGroup $group): static
{
$this->group = $group;
return $this;
}
public function getGroup(): ?ColumnGroup
{
return $this->group;
}
}
@@ -0,0 +1,22 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Filament\Tables\Columns\Layout\Component;
trait BelongsToLayout
{
protected ?Component $layout = null;
public function layout(?Component $layout): static
{
$this->layout = $layout;
return $this;
}
public function getLayout(): ?Component
{
return $this->layout;
}
}
@@ -0,0 +1,28 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
trait BelongsToTable
{
protected ?Table $table = null;
public function table(?Table $table): static
{
$this->table = $table;
return $this;
}
public function getTable(): Table
{
return $this->table;
}
public function getLivewire(): HasTable
{
return $this->getTable()->getLivewire();
}
}
@@ -0,0 +1,13 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Filament\Support\Concerns\CanBeCopied as BaseTrait;
/**
* @deprecated Use `Filament\Support\Concerns\CanBeCopied` instead.
*/
trait CanBeCopied /** @phpstan-ignore trait.unused */
{
use BaseTrait;
}
@@ -0,0 +1,51 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Closure;
trait CanBeDisabled
{
protected bool | Closure $isDisabled = false;
protected bool | Closure $isClickDisabled = false;
public function disabled(bool | Closure $condition = true): static
{
$this->isDisabled = $condition;
return $this;
}
public function disabledClick(bool | Closure $condition = true): static
{
$this->isClickDisabled = $condition;
return $this;
}
/**
* @deprecated Use `disabledClick()` instead.
*/
public function disableClick(bool | Closure $condition = true): static
{
$this->disabledClick($condition);
return $this;
}
public function isDisabled(): bool
{
return (bool) $this->evaluate($this->isDisabled);
}
public function isEnabled(): bool
{
return ! $this->isDisabled();
}
public function isClickDisabled(): bool
{
return (bool) $this->evaluate($this->isClickDisabled);
}
}
@@ -0,0 +1,80 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Closure;
use Filament\Tables\Contracts\HasTable;
use Illuminate\Support\Arr;
trait CanBeHidden
{
use CanBeHiddenResponsively;
protected bool | Closure $isHidden = false;
protected bool | Closure $isVisible = true;
public function hidden(bool | Closure $condition = true): static
{
$this->isHidden = $condition;
return $this;
}
/**
* @param string | array<string> $livewireComponents
*/
public function hiddenOn(string | array $livewireComponents): static
{
$this->hidden(static function (HasTable $livewire) use ($livewireComponents): bool {
foreach (Arr::wrap($livewireComponents) as $livewireComponent) {
if ($livewire instanceof $livewireComponent) {
return true;
}
}
return false;
});
return $this;
}
public function visible(bool | Closure $condition = true): static
{
$this->isVisible = $condition;
return $this;
}
/**
* @param string | array<string> $livewireComponents
*/
public function visibleOn(string | array $livewireComponents): static
{
$this->visible(static function (HasTable $livewire) use ($livewireComponents): bool {
foreach (Arr::wrap($livewireComponents) as $livewireComponent) {
if ($livewire instanceof $livewireComponent) {
return true;
}
}
return false;
});
return $this;
}
public function isHidden(): bool
{
if ($this->evaluate($this->isHidden)) {
return true;
}
return ! $this->evaluate($this->isVisible);
}
public function isVisible(): bool
{
return ! $this->isHidden();
}
}
@@ -0,0 +1,36 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Closure;
trait CanBeHiddenResponsively
{
protected string | Closure | null $hiddenFrom = null;
protected string | Closure | null $visibleFrom = null;
public function hiddenFrom(string | Closure | null $breakpoint): static
{
$this->hiddenFrom = $breakpoint;
return $this;
}
public function visibleFrom(string | Closure | null $breakpoint): static
{
$this->visibleFrom = $breakpoint;
return $this;
}
public function getHiddenFrom(): ?string
{
return $this->evaluate($this->hiddenFrom);
}
public function getVisibleFrom(): ?string
{
return $this->evaluate($this->visibleFrom);
}
}
@@ -0,0 +1,22 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Closure;
trait CanBeInline
{
protected bool | Closure $isInline = false;
public function inline(bool | Closure $condition = true): static
{
$this->isInline = $condition;
return $this;
}
public function isInline(): bool
{
return (bool) $this->evaluate($this->isInline);
}
}
@@ -0,0 +1,92 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
trait CanBeSearchable
{
protected bool $isGloballySearchable = false;
protected bool $isIndividuallySearchable = false;
protected bool | Closure $isSearchable = false;
/**
* @var array<string> | null
*/
protected ?array $searchColumns = null;
protected ?Closure $searchQuery = null;
protected bool | Closure | null $isSearchForcedCaseInsensitive = null;
/**
* @param bool | array<string> | string | Closure $condition
*/
public function searchable(
bool | array | string | Closure $condition = true,
?Closure $query = null,
bool $isIndividual = false,
bool $isGlobal = true,
): static {
if (is_bool($condition)) {
$this->isSearchable = $condition;
$this->searchColumns = null;
} else {
$this->isSearchable = true;
$this->searchColumns = Arr::wrap($condition);
}
$this->isGloballySearchable = $isGlobal;
$this->isIndividuallySearchable = $isIndividual;
$this->searchQuery = $query;
return $this;
}
public function forceSearchCaseInsensitive(bool | Closure | null $condition = true): static
{
$this->isSearchForcedCaseInsensitive = $condition;
return $this;
}
/**
* @return array<string>
*/
public function getSearchColumns(Model $record): array
{
return $this->searchColumns ?? $this->getDefaultSearchColumns($record);
}
public function isSearchable(): bool
{
return $this->evaluate($this->isSearchable);
}
public function isGloballySearchable(): bool
{
return $this->isSearchable() && $this->isGloballySearchable;
}
public function isIndividuallySearchable(): bool
{
return $this->isSearchable() && $this->isIndividuallySearchable;
}
public function isSearchForcedCaseInsensitive(): ?bool
{
return $this->evaluate($this->isSearchForcedCaseInsensitive);
}
/**
* @return array{0: string}
*/
public function getDefaultSearchColumns(Model $record): array
{
return [$this->getFullAttributeName($record)];
}
}
@@ -0,0 +1,57 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Closure;
use Illuminate\Database\Eloquent\Model;
trait CanBeSortable
{
protected bool | Closure $isSortable = false;
/**
* @var array<string> | null
*/
protected ?array $sortColumns = [];
protected ?Closure $sortQuery = null;
/**
* @param bool | array<string> | Closure $condition
*/
public function sortable(bool | array | Closure $condition = true, ?Closure $query = null): static
{
if (is_array($condition)) {
$this->isSortable = true;
$this->sortColumns = $condition;
} else {
$this->isSortable = $condition;
$this->sortColumns = null;
}
$this->sortQuery = $query;
return $this;
}
/**
* @return array<string>
*/
public function getSortColumns(Model $record): array
{
return $this->sortColumns ?? $this->getDefaultSortColumns($record);
}
public function isSortable(): bool
{
return (bool) $this->evaluate($this->isSortable);
}
/**
* @return array{0: string}
*/
public function getDefaultSortColumns(Model $record): array
{
return [$this->getFullAttributeName($record)];
}
}
@@ -0,0 +1,59 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Closure;
use Filament\Tables\Columns\Summarizers\Summarizer;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
trait CanBeSummarized
{
/**
* @var array<string | int, Summarizer>
*/
protected array $summarizers = [];
/**
* @param array<Summarizer> | Summarizer $summarizers
*/
public function summarize(array | Summarizer $summarizers): static
{
foreach (Arr::wrap($summarizers) as $summarizer) {
$summarizer->column($this);
if (filled($id = $summarizer->getId())) {
$this->summarizers[$id] = $summarizer;
} else {
$this->summarizers[] = $summarizer;
}
}
return $this;
}
public function getSummarizer(string $id): ?Summarizer
{
return $this->getSummarizers()[$id] ?? null;
}
/**
* @return array<string | int, Summarizer>
*/
public function getSummarizers(Builder | Closure | null $query = null): array
{
if ($query) {
return array_filter(
$this->summarizers,
fn (Summarizer $summarizer): bool => $summarizer->query($query)->isVisible(),
);
}
return $this->summarizers;
}
public function hasSummary(Builder | Closure | null $query = null): bool
{
return (bool) count($this->getSummarizers($query));
}
}
@@ -0,0 +1,57 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Closure;
use Filament\Tables\Columns\ColumnGroup;
trait CanBeToggled
{
protected bool | Closure $isToggleable = false;
protected bool | Closure $isToggledHiddenByDefault = false;
public function toggleable(bool | Closure $condition = true, bool | Closure $isToggledHiddenByDefault = false): static
{
$this->isToggleable = $condition;
$this->toggledHiddenByDefault($isToggledHiddenByDefault);
return $this;
}
public function toggledHiddenByDefault(bool | Closure $condition = true): static
{
$this->isToggledHiddenByDefault = $condition;
return $this;
}
public function isToggledHiddenByDefault(): bool
{
return (bool) $this->evaluate($this->isToggledHiddenByDefault);
}
public function isToggleable(): bool
{
if ($this->isHidden()) {
return false;
}
// When a column label is blank, it must be toggleable so that
// column groups can be collectively toggled on/off.
if ($this->getGroup() instanceof ColumnGroup && blank($this->getLabel())) {
return true;
}
return (bool) $this->evaluate($this->isToggleable);
}
public function isToggledHidden(): bool
{
if (! $this->isToggleable()) {
return false;
}
return $this->getTable()->getLivewire()->isTableColumnToggledHidden($this->getName());
}
}
@@ -0,0 +1,100 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Closure;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
trait CanBeValidated
{
/**
* @var array<array-key> | Closure
*/
protected array | Closure $rules = [];
protected string | Closure | null $validationAttribute = null;
/**
* @var array<string, string | Closure>
*/
protected array $validationMessages = [];
/**
* @param array<array-key> | Closure $rules
*/
public function rules(array | Closure $rules): static
{
$this->rules = $rules;
return $this;
}
public function validationAttribute(string | Closure | null $label): static
{
$this->validationAttribute = $label;
return $this;
}
/**
* @param array<string, string | Closure> $messages
*/
public function validationMessages(array $messages): static
{
$this->validationMessages = $messages;
return $this;
}
/**
* @return array<array-key>
*/
public function getRules(): array
{
$rules = $this->evaluate($this->rules);
if (! in_array('required', $rules)) {
$rules[] = 'nullable';
}
return $rules;
}
public function validate(mixed $input): void
{
$originalState = $this->getGetStateUsingCallback();
$this->getStateUsing($input);
try {
Validator::make(
['input' => $input],
['input' => $this->getRules()],
['input' => $this->getValidationMessages()],
['input' => $this->getValidationAttribute()],
)->validate();
} finally {
$this->getStateUsing($originalState);
}
}
public function getValidationAttribute(): string
{
return $this->evaluate($this->validationAttribute) ?? Str::lcfirst($this->getLabel());
}
/**
* @return array<string, string>
*/
public function getValidationMessages(): array
{
$messages = [];
foreach ($this->validationMessages as $rule => $message) {
$messages[$rule] = $this->evaluate($message);
}
return array_filter($messages);
}
}
@@ -0,0 +1,35 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Closure;
use Filament\Actions\Action;
use Filament\Tables\Contracts\HasTable;
use Illuminate\Database\Eloquent\Model;
trait CanCallAction
{
protected Closure | Action | string | null $action = null;
public function action(Closure | Action | string | null $action): static
{
if (is_string($action)) {
$action = function (HasTable $livewire, ?Model $record) use ($action) {
if ($record) {
return $livewire->{$action}($record);
}
return $livewire->{$action}();
};
}
$this->action = $action;
return $this;
}
public function getAction(): Closure | Action | null
{
return $this->action;
}
}
@@ -0,0 +1,504 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use BackedEnum;
use Closure;
use Filament\Support\Concerns\CanConfigureCommonMark;
use Filament\Support\Contracts\HasLabel as LabelInterface;
use Filament\Support\Enums\ArgumentValue;
use Filament\Support\Facades\FilamentTimezone;
use Filament\Tables\Columns\TextColumn;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Carbon;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Number;
use Illuminate\Support\Str;
trait CanFormatState
{
use CanConfigureCommonMark;
protected ?Closure $formatStateUsing = null;
protected int | Closure | null $characterLimit = null;
protected string | Closure | null $characterLimitEnd = null;
protected int | Closure | null $wordLimit = null;
protected string | Closure | null $wordLimitEnd = null;
protected string | Htmlable | Closure | null $prefix = null;
protected string | Htmlable | Closure | null $suffix = null;
protected string | Closure | null $timezone = null;
protected bool | Closure $isHtml = false;
protected bool | Closure $isMarkdown = false;
protected bool $isDate = false;
protected bool $isDateTime = false;
protected bool $isMoney = false;
protected bool $isNumeric = false;
protected bool $isTime = false;
public function markdown(bool | Closure $condition = true): static
{
$this->isMarkdown = $condition;
return $this;
}
public function date(string | Closure | null $format = null, string | Closure | null $timezone = null): static
{
$this->isDate = true;
$this->formatStateUsing(static function (TextColumn $column, $state) use ($format, $timezone): ?string {
if (blank($state)) {
return null;
}
return Carbon::parse($state)
->setTimezone($column->evaluate($timezone) ?? $column->getTimezone())
->translatedFormat($column->evaluate($format) ?? $column->getTable()->getDefaultDateDisplayFormat());
});
return $this;
}
public function dateTime(string | Closure | null $format = null, string | Closure | null $timezone = null): static
{
$this->isDateTime = true;
$format ??= fn (TextColumn $column): string => $column->getTable()->getDefaultDateTimeDisplayFormat();
$this->date($format, $timezone);
return $this;
}
public function isoDate(string | Closure | null $format = null, string | Closure | null $timezone = null): static
{
$this->isDate = true;
$format ??= fn (TextColumn $column): string => $column->getTable()->getDefaultIsoDateDisplayFormat();
$this->formatStateUsing(static function (TextColumn $column, $state) use ($format, $timezone): ?string {
if (blank($state)) {
return null;
}
return Carbon::parse($state)
->setTimezone($column->evaluate($timezone) ?? $column->getTimezone())
->isoFormat($column->evaluate($format) ?? $column->getTable()->getDefaultIsoDateDisplayFormat());
});
return $this;
}
public function isoDateTime(string | Closure | null $format = null, string | Closure | null $timezone = null): static
{
$this->isDateTime = true;
$format ??= fn (TextColumn $column): string => $column->getTable()->getDefaultIsoDateTimeDisplayFormat();
$this->isoDate($format, $timezone);
return $this;
}
public function since(string | Closure | null $timezone = null): static
{
$this->isDateTime = true;
$this->formatStateUsing(static function (TextColumn $column, $state) use ($timezone): ?string {
if (blank($state)) {
return null;
}
return Carbon::parse($state)
->setTimezone($column->evaluate($timezone) ?? $column->getTimezone())
->diffForHumans();
});
return $this;
}
public function dateTooltip(string | Closure | null $format = null, string | Closure | null $timezone = null): static
{
$this->tooltip(static function (TextColumn $column, mixed $state) use ($format, $timezone): ?string {
if (blank($state)) {
return null;
}
return Carbon::parse($state)
->setTimezone($column->evaluate($timezone) ?? $column->getTimezone())
->translatedFormat($column->evaluate($format) ?? $column->getTable()->getDefaultDateDisplayFormat());
});
return $this;
}
public function dateTimeTooltip(string | Closure | null $format = null, string | Closure | null $timezone = null): static
{
$format ??= fn (TextColumn $column): string => $column->getTable()->getDefaultDateTimeDisplayFormat();
$this->dateTooltip($format, $timezone);
return $this;
}
public function timeTooltip(string | Closure | null $format = null, string | Closure | null $timezone = null): static
{
$format ??= fn (TextColumn $column): string => $column->getTable()->getDefaultTimeDisplayFormat();
$this->dateTooltip($format, $timezone);
return $this;
}
public function sinceTooltip(string | Closure | null $timezone = null): static
{
$this->tooltip(static function (TextColumn $column, mixed $state) use ($timezone): ?string {
if (blank($state)) {
return null;
}
return Carbon::parse($state)
->setTimezone($column->evaluate($timezone) ?? $column->getTimezone())
->diffForHumans();
});
return $this;
}
public function isoDateTooltip(string | Closure | null $format = null, string | Closure | null $timezone = null): static
{
$format ??= fn (TextColumn $column): string => $column->getTable()->getDefaultIsoDateDisplayFormat();
$this->tooltip(static function (TextColumn $column, mixed $state) use ($format, $timezone): ?string {
if (blank($state)) {
return null;
}
return Carbon::parse($state)
->setTimezone($column->evaluate($timezone) ?? $column->getTimezone())
->isoFormat($column->evaluate($format) ?? $column->getTable()->getDefaultIsoDateDisplayFormat());
});
return $this;
}
public function isoDateTimeTooltip(string | Closure | null $format = null, string | Closure | null $timezone = null): static
{
$format ??= fn (TextColumn $column): string => $column->getTable()->getDefaultIsoDateTimeDisplayFormat();
$this->isoDateTooltip($format, $timezone);
return $this;
}
public function isoTimeTooltip(string | Closure | null $format = null, string | Closure | null $timezone = null): static
{
$format ??= fn (TextColumn $column): string => $column->getTable()->getDefaultIsoTimeDisplayFormat();
$this->isoDateTooltip($format, $timezone);
return $this;
}
public function money(string | BackedEnum | Closure | null $currency = null, int | Closure $divideBy = 0, string | BackedEnum | Closure | null $locale = null, int | Closure | null $decimalPlaces = null): static
{
$this->isMoney = true;
$this->formatStateUsing(static function (TextColumn $column, $state) use ($currency, $divideBy, $locale, $decimalPlaces): ?string {
if (blank($state)) {
return null;
}
if (! is_numeric($state)) {
return $state;
}
$currency = $column->evaluate($currency) ?? $column->getTable()->getDefaultCurrency();
$locale = $column->evaluate($locale) ?? $column->getTable()->getDefaultNumberLocale() ?? config('app.locale');
$decimalPlaces = $column->evaluate($decimalPlaces);
if ($divideBy = $column->evaluate($divideBy)) {
$state /= $divideBy;
}
if ($currency instanceof BackedEnum) {
$currency = (string) $currency->value;
}
if ($locale instanceof BackedEnum) {
$locale = (string) $locale->value;
}
return Number::currency($state, $currency, $locale, $decimalPlaces);
});
return $this;
}
public function numeric(int | Closure | null $decimalPlaces = null, string | Closure | null | ArgumentValue $decimalSeparator = ArgumentValue::Default, string | Closure | null | ArgumentValue $thousandsSeparator = ArgumentValue::Default, int | Closure | null $maxDecimalPlaces = null, string | Closure | null $locale = null): static
{
$this->isNumeric = true;
$this->formatStateUsing(static function (TextColumn $column, $state) use ($decimalPlaces, $decimalSeparator, $locale, $maxDecimalPlaces, $thousandsSeparator): ?string {
if (blank($state)) {
return null;
}
if (! is_numeric($state)) {
return $state;
}
$decimalPlaces = $column->evaluate($decimalPlaces);
$decimalSeparator = $column->evaluate($decimalSeparator);
$thousandsSeparator = $column->evaluate($thousandsSeparator);
if (
($decimalSeparator !== ArgumentValue::Default) ||
($thousandsSeparator !== ArgumentValue::Default)
) {
return number_format(
$state,
$decimalPlaces,
$decimalSeparator === ArgumentValue::Default ? '.' : $decimalSeparator,
$thousandsSeparator === ArgumentValue::Default ? ',' : $thousandsSeparator,
);
}
$locale = $column->evaluate($locale) ?? $column->getTable()->getDefaultNumberLocale() ?? config('app.locale');
return Number::format($state, $decimalPlaces, $column->evaluate($maxDecimalPlaces), $locale);
});
return $this;
}
public function time(string | Closure | null $format = null, string | Closure | null $timezone = null): static
{
$this->isTime = true;
$format ??= fn (TextColumn $column): string => $column->getTable()->getDefaultTimeDisplayFormat();
$this->date($format, $timezone);
return $this;
}
public function isoTime(string | Closure | null $format = null, string | Closure | null $timezone = null): static
{
$this->isTime = true;
$format ??= fn (TextColumn $column): string => $column->getTable()->getDefaultIsoTimeDisplayFormat();
$this->isoDate($format, $timezone);
return $this;
}
public function timezone(string | Closure | null $timezone): static
{
$this->timezone = $timezone;
return $this;
}
public function limit(int | Closure | null $length = 100, string | Closure | null $end = '...'): static
{
$this->characterLimit = $length;
$this->characterLimitEnd = $end;
return $this;
}
public function words(int | Closure | null $words = 100, string | Closure | null $end = '...'): static
{
$this->wordLimit = $words;
$this->wordLimitEnd = $end;
return $this;
}
public function prefix(string | Htmlable | Closure | null $prefix): static
{
$this->prefix = $prefix;
return $this;
}
public function suffix(string | Htmlable | Closure | null $suffix): static
{
$this->suffix = $suffix;
return $this;
}
public function html(bool | Closure $condition = true): static
{
$this->isHtml = $condition;
return $this;
}
public function formatStateUsing(?Closure $callback): static
{
$this->formatStateUsing = $callback;
return $this;
}
public function formatState(mixed $state): mixed
{
$isHtml = $this->isHtml();
$state = $this->evaluate($this->formatStateUsing ?? $state, [
'state' => $state,
]);
if (is_array($state)) {
$state = json_encode($state);
}
if ($isHtml) {
if ($this->isMarkdown()) {
$state = Str::markdown($state, $this->getCommonMarkOptions(), $this->getCommonMarkExtensions());
}
$state = Str::sanitizeHtml($state);
}
if ($state instanceof Htmlable) {
$isHtml = true;
$state = $state->toHtml();
}
if ($state instanceof LabelInterface) {
$state = $state->getLabel();
}
if (! $isHtml) {
if ($characterLimit = $this->getCharacterLimit()) {
$state = Str::limit($state, $characterLimit, $this->getCharacterLimitEnd());
}
if ($wordLimit = $this->getWordLimit()) {
$state = Str::words($state, $wordLimit, $this->getWordLimitEnd());
}
}
$prefix = $this->getPrefix();
$suffix = $this->getSuffix();
if (
(($prefix instanceof Htmlable) || ($suffix instanceof Htmlable)) &&
(! $isHtml)
) {
$isHtml = true;
$state = e($state);
}
if (filled($prefix)) {
if ($prefix instanceof Htmlable) {
$prefix = $prefix->toHtml();
} elseif ($isHtml) {
$prefix = e($prefix);
}
$state = $prefix . $state;
}
if (filled($suffix)) {
if ($suffix instanceof Htmlable) {
$suffix = $suffix->toHtml();
} elseif ($isHtml) {
$suffix = e($suffix);
}
$state .= $suffix;
}
return $isHtml ? new HtmlString($state) : $state;
}
public function getCharacterLimit(): ?int
{
return $this->evaluate($this->characterLimit);
}
public function getCharacterLimitEnd(): ?string
{
return $this->evaluate($this->characterLimitEnd);
}
public function getWordLimit(): ?int
{
return $this->evaluate($this->wordLimit);
}
public function getWordLimitEnd(): ?string
{
return $this->evaluate($this->wordLimitEnd);
}
public function getTimezone(): string
{
return $this->evaluate($this->timezone) ?? ($this->isDateTime() ? FilamentTimezone::get() : config('app.timezone'));
}
public function isHtml(): bool
{
return $this->evaluate($this->isHtml) || $this->isMarkdown();
}
public function getPrefix(): string | Htmlable | null
{
return $this->evaluate($this->prefix);
}
public function getSuffix(): string | Htmlable | null
{
return $this->evaluate($this->suffix);
}
public function isMarkdown(): bool
{
return (bool) $this->evaluate($this->isMarkdown);
}
public function isDate(): bool
{
return $this->isDate;
}
public function isDateTime(): bool
{
return $this->isDateTime;
}
public function isMoney(): bool
{
return $this->isMoney;
}
public function isNumeric(): bool
{
return $this->isNumeric;
}
public function isTime(): bool
{
return $this->isTime;
}
}
@@ -0,0 +1,54 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Closure;
trait CanOpenUrl
{
protected bool | Closure $shouldOpenUrlInNewTab = false;
protected string | Closure | null $url = null;
public function openUrlInNewTab(bool | Closure $condition = true): static
{
$this->shouldOpenUrlInNewTab = $condition;
return $this;
}
public function url(string | Closure | null $url, bool | Closure $shouldOpenInNewTab = false): static
{
$this->openUrlInNewTab($shouldOpenInNewTab);
$this->url = $url;
return $this;
}
public function getUrl(mixed $state = null): ?string
{
if (func_num_args() === 1) {
return $this->hasStateBasedUrls()
? $this->evaluate($this->url, [
'state' => $state,
])
: null;
}
if ($this->hasStateBasedUrls()) {
return null;
}
return $this->evaluate($this->url);
}
public function hasStateBasedUrls(): bool
{
return $this->evaluationValueIsFunctionAndHasParameter($this->url, parameterName: 'state');
}
public function shouldOpenUrlInNewTab(): bool
{
return (bool) $this->evaluate($this->shouldOpenUrlInNewTab);
}
}
@@ -0,0 +1,97 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Arr;
trait CanUpdateState
{
protected ?Closure $updateStateUsing = null;
protected ?Closure $beforeStateUpdated = null;
protected ?Closure $afterStateUpdated = null;
public function updateStateUsing(?Closure $callback): static
{
$this->updateStateUsing = $callback;
return $this;
}
public function beforeStateUpdated(?Closure $callback): static
{
$this->beforeStateUpdated = $callback;
return $this;
}
public function afterStateUpdated(?Closure $callback): static
{
$this->afterStateUpdated = $callback;
return $this;
}
public function updateState(mixed $state): mixed
{
if (blank($state)) {
$state = null;
}
$this->callBeforeStateUpdated($state);
if ($this->updateStateUsing !== null) {
try {
return $this->evaluate($this->updateStateUsing, [
'state' => $state,
]);
} finally {
$this->callAfterStateUpdated($state);
}
}
$record = $this->getRecord();
$columnName = $this->getName();
if ($this->hasRelationship($record)) {
$columnName = $this->getFullAttributeName($record);
$columnRelationshipName = $this->getRelationshipName($record);
$record = Arr::get(
$record->load($columnRelationshipName),
$columnRelationshipName,
);
} elseif (
(($tableRelationship = $this->getTable()->getRelationship()) instanceof BelongsToMany) &&
in_array($this->getAttributeName($record), $tableRelationship->getPivotColumns())
) {
$record = $record->getRelationValue($tableRelationship->getPivotAccessor());
}
if (! ($record instanceof Model)) {
return null;
}
$record->setAttribute((string) str($columnName)->replace('.', '->'), $state);
$record->save();
$this->callAfterStateUpdated($state);
return $state;
}
public function callBeforeStateUpdated(mixed $state): mixed
{
return $this->evaluate($this->beforeStateUpdated, ['state' => $state]);
}
public function callAfterStateUpdated(mixed $state): mixed
{
return $this->evaluate($this->afterStateUpdated, ['state' => $state]);
}
}
@@ -0,0 +1,75 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Closure;
use Filament\Support\Contracts\HasColor as ColorInterface;
use Filament\Tables\Columns\Column;
trait HasColor
{
/**
* @var string | array<string> | bool | Closure | null
*/
protected string | array | bool | Closure | null $color = null;
/**
* @param string | array<string> | bool | Closure | null $color
*/
public function color(string | array | bool | Closure | null $color): static
{
$this->color = $color;
return $this;
}
/**
* @param array<mixed> | Closure $colors
*/
public function colors(array | Closure $colors): static
{
$this->color(function (Column $column, $state) use ($colors) {
$colors = $column->evaluate($colors);
$color = null;
foreach ($colors as $conditionalColor => $condition) {
if (is_numeric($conditionalColor)) {
$color = $condition;
} elseif ($condition instanceof Closure && $column->evaluate($condition)) {
$color = $conditionalColor;
} elseif ($condition === $state) {
$color = $conditionalColor;
}
}
return $color;
});
return $this;
}
/**
* @return string | array<string> | null
*/
public function getColor(mixed $state): string | array | null
{
$color = $this->evaluate($this->color, [
'state' => $state,
]);
if ($color === false) {
return null;
}
if (filled($color)) {
return $color;
}
if (! $state instanceof ColorInterface) {
return null;
}
return $state->getColor();
}
}
@@ -0,0 +1,34 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Closure;
use Illuminate\Contracts\Support\Htmlable;
trait HasDescription
{
protected string | Htmlable | Closure | null $descriptionAbove = null;
protected string | Htmlable | Closure | null $descriptionBelow = null;
public function description(string | Htmlable | Closure | null $description, string | Closure | null $position = 'below'): static
{
if ($position === 'above') {
$this->descriptionAbove = $description;
} else {
$this->descriptionBelow = $description;
}
return $this;
}
public function getDescriptionAbove(): string | Htmlable | null
{
return $this->evaluate($this->descriptionAbove);
}
public function getDescriptionBelow(): string | Htmlable | null
{
return $this->evaluate($this->descriptionBelow);
}
}
@@ -0,0 +1,47 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Closure;
use Illuminate\View\ComponentAttributeBag;
trait HasExtraCellAttributes
{
/**
* @var array<array<mixed> | Closure>
*/
protected array $extraCellAttributes = [];
/**
* @param array<mixed> | Closure $attributes
*/
public function extraCellAttributes(array | Closure $attributes, bool $merge = false): static
{
if ($merge) {
$this->extraCellAttributes[] = $attributes;
} else {
$this->extraCellAttributes = [$attributes];
}
return $this;
}
/**
* @return array<mixed>
*/
public function getExtraCellAttributes(): array
{
$temporaryAttributeBag = new ComponentAttributeBag;
foreach ($this->extraCellAttributes as $extraCellAttributes) {
$temporaryAttributeBag = $temporaryAttributeBag->merge($this->evaluate($extraCellAttributes), escape: false);
}
return $temporaryAttributeBag->getAttributes();
}
public function getExtraCellAttributeBag(): ComponentAttributeBag
{
return new ComponentAttributeBag($this->getExtraCellAttributes());
}
}
@@ -0,0 +1,47 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Closure;
use Illuminate\View\ComponentAttributeBag;
trait HasExtraHeaderAttributes
{
/**
* @var array<array<mixed> | Closure>
*/
protected array $extraHeaderAttributes = [];
/**
* @param array<mixed> | Closure $attributes
*/
public function extraHeaderAttributes(array | Closure $attributes, bool $merge = false): static
{
if ($merge) {
$this->extraHeaderAttributes[] = $attributes;
} else {
$this->extraHeaderAttributes = [$attributes];
}
return $this;
}
/**
* @return array<mixed>
*/
public function getExtraHeaderAttributes(): array
{
$temporaryAttributeBag = new ComponentAttributeBag;
foreach ($this->extraHeaderAttributes as $extraHeaderAttributes) {
$temporaryAttributeBag = $temporaryAttributeBag->merge($this->evaluate($extraHeaderAttributes), escape: false);
}
return $temporaryAttributeBag->getAttributes();
}
public function getExtraHeaderAttributeBag(): ComponentAttributeBag
{
return new ComponentAttributeBag($this->getExtraHeaderAttributes());
}
}
@@ -0,0 +1,93 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use BackedEnum;
use Closure;
use Filament\Support\Contracts\HasIcon as IconInterface;
use Filament\Support\Enums\IconPosition;
use Filament\Tables\Columns\Column;
use Illuminate\Contracts\Support\Htmlable;
trait HasIcon
{
protected string | BackedEnum | Htmlable | bool | Closure | null $icon = null;
protected IconPosition | string | Closure | null $iconPosition = null;
public function icon(string | BackedEnum | Htmlable | bool | Closure | null $icon): static
{
$this->icon = $icon;
return $this;
}
/**
* @param array<mixed> | Closure $icons
*/
public function icons(array | Closure $icons): static
{
$this->icon(function (Column $column, $state) use ($icons) {
$icons = $column->evaluate($icons);
$icon = null;
foreach ($icons as $conditionalIcon => $condition) {
if (is_numeric($conditionalIcon)) {
$icon = $condition;
} elseif ($condition instanceof Closure && $column->evaluate($condition)) {
$icon = $conditionalIcon;
} elseif ($condition === $state) {
$icon = $conditionalIcon;
}
}
return $icon;
});
return $this;
}
public function iconPosition(IconPosition | string | Closure | null $iconPosition): static
{
$this->iconPosition = $iconPosition;
return $this;
}
public function getIcon(mixed $state): string | BackedEnum | Htmlable | null
{
$icon = $this->evaluate($this->icon, [
'state' => $state,
]);
if ($icon === false) {
return null;
}
if (filled($icon)) {
return $icon;
}
if (! $state instanceof IconInterface) {
return null;
}
return $state->getIcon();
}
public function getIconPosition(): IconPosition
{
$position = $this->evaluate($this->iconPosition);
if ($position instanceof IconPosition) {
return $position;
}
if (blank($position)) {
return IconPosition::Before;
}
return IconPosition::tryFrom($position) ?? IconPosition::Before;
}
}
@@ -0,0 +1,33 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Closure;
trait HasIconColor
{
/**
* @var string | array<string> | Closure | null
*/
protected string | array | Closure | null $iconColor = null;
/**
* @param string | array<string> | Closure | null $color
*/
public function iconColor(string | array | Closure | null $color): static
{
$this->iconColor = $color;
return $this;
}
/**
* @return string | array<int | string, string | int> | null
*/
public function getIconColor(mixed $state): string | array | null
{
return $this->evaluate($this->iconColor, [
'state' => $state,
]);
}
}
@@ -0,0 +1,39 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Closure;
use Illuminate\Contracts\Support\Htmlable;
trait HasLabel
{
protected string | Htmlable | Closure | null $label = null;
protected bool $shouldTranslateLabel = false;
public function label(string | Htmlable | Closure | null $label): static
{
$this->label = $label;
return $this;
}
public function translateLabel(bool $shouldTranslateLabel = true): static
{
$this->shouldTranslateLabel = $shouldTranslateLabel;
return $this;
}
public function getLabel(): string | Htmlable
{
$label = $this->evaluate($this->label) ?? (string) str($this->getName())
->beforeLast('.')
->afterLast('.')
->kebab()
->replace(['-', '_'], ' ')
->ucfirst();
return $this->shouldTranslateLabel ? __($label) : $label;
}
}
@@ -0,0 +1,20 @@
<?php
namespace Filament\Tables\Columns\Concerns;
trait HasName
{
protected string $name;
public function name(string $name): static
{
$this->name = $name;
return $this;
}
public function getName(): string
{
return $this->name;
}
}
@@ -0,0 +1,45 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Illuminate\Database\Eloquent\Model;
trait HasRecord
{
/**
* @var Model | array<string, mixed> | null
*/
protected Model | array | null $record = null;
protected ?string $recordKey = null;
/**
* @param Model | array<string, mixed> $record
*/
public function record(Model | array $record): static
{
$this->record = $record;
return $this;
}
public function recordKey(?string $recordKey): static
{
$this->recordKey = $recordKey;
return $this;
}
public function getRecordKey(): ?string
{
return $this->recordKey;
}
/**
* @return Model | array<string, mixed> | null
*/
public function getRecord(): Model | array | null
{
return $this->record ?? $this->getLayout()?->getRecord();
}
}
@@ -0,0 +1,22 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use stdClass;
trait HasRowLoopObject
{
protected ?stdClass $loop = null;
public function rowLoop(?stdClass $loop): static
{
$this->loop = $loop;
return $this;
}
public function getRowLoop(): ?stdClass
{
return $this->loop;
}
}
@@ -0,0 +1,22 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Closure;
trait HasSpace
{
protected int | string | Closure | null $space = null;
public function space(int | string | Closure | null $space = 1): static
{
$this->space = $space;
return $this;
}
public function getSpace(): int | string | null
{
return $this->evaluate($this->space);
}
}
@@ -0,0 +1,39 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Closure;
use Illuminate\Contracts\Support\Htmlable;
trait HasTooltip
{
protected string | Htmlable | Closure | null $tooltip = null;
protected string | Htmlable | Closure | null $emptyTooltip = null;
public function tooltip(string | Htmlable | Closure | null $tooltip): static
{
$this->tooltip = $tooltip;
return $this;
}
public function getTooltip(mixed $state = null): string | Htmlable | null
{
return $this->evaluate($this->tooltip, [
'state' => $state,
]);
}
public function emptyTooltip(string | Htmlable | Closure | null $tooltip): static
{
$this->emptyTooltip = $tooltip;
return $this;
}
public function getEmptyTooltip(): string | Htmlable | null
{
return $this->evaluate($this->emptyTooltip);
}
}
@@ -0,0 +1,165 @@
<?php
namespace Filament\Tables\Columns\Concerns;
use Filament\Support\Services\RelationshipOrderer;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Arr;
use Znck\Eloquent\Relations\BelongsToThrough;
use function Filament\Support\generate_search_column_expression;
use function Filament\Support\generate_search_term_expression;
trait InteractsWithTableQuery
{
public function applyRelationshipAggregates(EloquentBuilder | Relation $query): EloquentBuilder | Relation
{
return $query->when(
filled([$this->getRelationshipToAvg(), $this->getColumnToAvg()]),
fn ($query) => $query->withAvg($this->getRelationshipToAvg(), $this->getColumnToAvg())
)->when(
filled($this->getRelationshipsToCount()),
fn ($query) => $query->withCount(Arr::wrap($this->getRelationshipsToCount()))
)->when(
filled($this->getRelationshipsToExistenceCheck()),
fn ($query) => $query->withExists(Arr::wrap($this->getRelationshipsToExistenceCheck()))
)->when(
filled([$this->getRelationshipToMax(), $this->getColumnToMax()]),
fn ($query) => $query->withMax($this->getRelationshipToMax(), $this->getColumnToMax())
)->when(
filled([$this->getRelationshipToMin(), $this->getColumnToMin()]),
fn ($query) => $query->withMin($this->getRelationshipToMin(), $this->getColumnToMin())
)->when(
filled([$this->getRelationshipToSum(), $this->getColumnToSum()]),
fn ($query) => $query->withSum($this->getRelationshipToSum(), $this->getColumnToSum())
);
}
public function applyEagerLoading(EloquentBuilder | Relation $query): EloquentBuilder | Relation
{
if (! $this->hasRelationship($query->getModel())) {
return $query;
}
$relationshipName = $this->getRelationshipName($query->getModel());
if (array_key_exists($relationshipName, $query->getEagerLoads())) {
return $query;
}
return $query->with([$relationshipName]);
}
public function applySearchConstraint(EloquentBuilder $query, string $search, bool &$isFirst): EloquentBuilder
{
if ($this->searchQuery) {
$whereClause = $isFirst ? 'where' : 'orWhere';
$query->{$whereClause}(
fn ($query) => $this->evaluate($this->searchQuery, [
'query' => $query,
'search' => $search,
'searchQuery' => $search,
]),
);
$isFirst = false;
return $query;
}
/** @var Connection $databaseConnection */
$databaseConnection = $query->getConnection();
$model = $query->getModel();
$isSearchForcedCaseInsensitive = $this->isSearchForcedCaseInsensitive();
$nonTranslatableSearch = generate_search_term_expression($search, $isSearchForcedCaseInsensitive, $databaseConnection);
$translatableContentDriver = $this->getLivewire()->makeFilamentTranslatableContentDriver();
foreach ($this->getSearchColumns($query->getModel()) as $searchColumn) {
$whereClause = $isFirst ? 'where' : 'orWhere';
$query->when(
$translatableContentDriver?->isAttributeTranslatable($model::class, attribute: $searchColumn),
fn (EloquentBuilder $query): EloquentBuilder => $translatableContentDriver->applySearchConstraintToQuery($query, $searchColumn, $search, $whereClause, $isSearchForcedCaseInsensitive),
fn (EloquentBuilder $query) => $query->when(
$this->hasRelationship($query->getModel()),
function (EloquentBuilder $query) use ($model, $whereClause, $searchColumn, $isSearchForcedCaseInsensitive, $databaseConnection, $nonTranslatableSearch): EloquentBuilder {
$relationshipName = $this->getRelationshipName($query->getModel());
$relationship = $this->getRelationship($query->getModel(), $relationshipName);
$relatedTable = $model->getTable();
if ($relationship instanceof BelongsToThrough) {
$relatedTable = $relationship->getRelated()->getTable();
$searchColumn = str($searchColumn)->startsWith("{$relatedTable}.")
? $searchColumn
: $relationship->getRelated()->qualifyColumn($searchColumn);
}
return $query->{"{$whereClause}Relation"}(
$relationshipName,
generate_search_column_expression($this->getJsonSafeColumnName($searchColumn, $relatedTable), $isSearchForcedCaseInsensitive, $databaseConnection),
'like',
"%{$nonTranslatableSearch}%",
);
},
fn (EloquentBuilder $query) => $query->{$whereClause}(
generate_search_column_expression($this->getJsonSafeColumnName($searchColumn, $model->getTable()), $isSearchForcedCaseInsensitive, $databaseConnection),
'like',
"%{$nonTranslatableSearch}%",
),
),
);
$isFirst = false;
}
return $query;
}
protected function getJsonSafeColumnName(string $column, string $tableName): string
{
if (str($column)->startsWith("{$tableName}.")) {
return (string) str($column)->after('.')->replace('.', '->')->prepend("{$tableName}.");
}
return (string) str($column)->replace('.', '->');
}
public function applySort(EloquentBuilder $query, string $direction = 'asc'): EloquentBuilder
{
if ($this->sortQuery) {
$this->evaluate($this->sortQuery, [
'direction' => $direction,
'query' => $query,
]);
return $query;
}
$relationshipName = $this->getRelationshipName($query->getModel());
foreach (array_reverse($this->getSortColumns($query->getModel())) as $sortColumn) {
$sortColumn = $this->getJsonSafeColumnName($sortColumn, $query->getModel()->getTable());
if (filled($relationshipName)) {
$query->orderBy(
app(RelationshipOrderer::class)->buildSubquery($query, $relationshipName, $sortColumn),
$direction
);
continue;
}
$query->orderBy($sortColumn, $direction);
}
return $query;
}
}
@@ -0,0 +1,10 @@
<?php
namespace Filament\Tables\Columns\Contracts;
interface Editable
{
public function validate(mixed $input): void;
public function updateState(mixed $state): mixed;
}
@@ -0,0 +1,345 @@
<?php
namespace Filament\Tables\Columns;
use BackedEnum;
use Closure;
use Filament\Support\Components\Contracts\HasEmbeddedView;
use Filament\Support\Concerns\CanWrap;
use Filament\Support\Enums\Alignment;
use Filament\Support\Enums\IconSize;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\View\Components\Columns\IconColumnComponent\IconComponent;
use Filament\Tables\View\TablesIconAlias;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Js;
use Illuminate\View\ComponentAttributeBag;
use function Filament\Support\generate_icon_html;
class IconColumn extends Column implements HasEmbeddedView
{
use CanWrap;
use Concerns\HasColor {
getColor as getBaseColor;
}
use Concerns\HasIcon {
getIcon as getBaseIcon;
}
protected bool | Closure | null $isBoolean = null;
/**
* @var string | array<string> | Closure | null
*/
protected string | array | Closure | null $falseColor = null;
protected string | BackedEnum | Htmlable | Closure | false | null $falseIcon = null;
/**
* @var string | array<string> | Closure | null
*/
protected string | array | Closure | null $trueColor = null;
protected string | BackedEnum | Htmlable | Closure | false | null $trueIcon = null;
protected bool | Closure $isListWithLineBreaks = false;
protected IconSize | string | Closure | null $size = null;
public function boolean(bool | Closure $condition = true): static
{
$this->isBoolean = $condition;
return $this;
}
public function listWithLineBreaks(bool | Closure $condition = true): static
{
$this->isListWithLineBreaks = $condition;
return $this;
}
/**
* @param string | array<int | string, string | int> | Closure | null $color
*/
public function false(string | BackedEnum | Htmlable | Closure | false | null $icon = null, string | array | Closure | null $color = null): static
{
$this->falseIcon($icon);
$this->falseColor($color);
return $this;
}
/**
* @param string | array<string> | Closure | null $color
*/
public function falseColor(string | array | Closure | null $color): static
{
$this->boolean();
$this->falseColor = $color;
return $this;
}
public function falseIcon(string | BackedEnum | Htmlable | Closure | false | null $icon): static
{
$this->boolean();
$this->falseIcon = $icon;
return $this;
}
/**
* @param string | array<int | string, string | int> | Closure | null $color
*/
public function true(string | BackedEnum | Htmlable | Closure | false | null $icon = null, string | array | Closure | null $color = null): static
{
$this->trueIcon($icon);
$this->trueColor($color);
return $this;
}
/**
* @param string | array<string> | Closure | null $color
*/
public function trueColor(string | array | Closure | null $color): static
{
$this->boolean();
$this->trueColor = $color;
return $this;
}
public function trueIcon(string | BackedEnum | Htmlable | Closure | false | null $icon): static
{
$this->boolean();
$this->trueIcon = $icon;
return $this;
}
/**
* @deprecated Use `icons()` instead.
*
* @param array<mixed> | Arrayable | Closure $options
*/
public function options(array | Arrayable | Closure $options): static
{
$this->icons($options);
return $this;
}
public function size(IconSize | string | Closure | null $size): static
{
$this->size = $size;
return $this;
}
public function getSize(mixed $state): IconSize | string | null
{
$size = $this->evaluate($this->size, [
'state' => $state,
]);
if (blank($size)) {
return null;
}
if ($size === 'base') {
return null;
}
if (is_string($size)) {
$size = IconSize::tryFrom($size) ?? $size;
}
return $size;
}
public function getIcon(mixed $state): string | BackedEnum | Htmlable | null
{
if (filled($icon = $this->getBaseIcon($state))) {
return $icon;
}
if (! $this->isBoolean()) {
return null;
}
if ($state === null) {
return null;
}
return $state ? $this->getTrueIcon() : $this->getFalseIcon();
}
/**
* @return string | array<int | string, string | int> | null
*/
public function getColor(mixed $state): string | array | null
{
if (filled($color = $this->getBaseColor($state))) {
return $color;
}
if (! $this->isBoolean()) {
return null;
}
if ($state === null) {
return null;
}
return $state ? $this->getTrueColor() : $this->getFalseColor();
}
/**
* @return string | array<string>
*/
public function getFalseColor(): string | array
{
return $this->evaluate($this->falseColor) ?? 'danger';
}
public function getFalseIcon(): string | BackedEnum | Htmlable | null
{
$icon = $this->evaluate($this->falseIcon);
if ($icon === false) {
return null;
}
return $icon
?? FilamentIcon::resolve(TablesIconAlias::COLUMNS_ICON_COLUMN_FALSE)
?? Heroicon::OutlinedXCircle;
}
/**
* @return string | array<string>
*/
public function getTrueColor(): string | array
{
return $this->evaluate($this->trueColor) ?? 'success';
}
public function getTrueIcon(): string | BackedEnum | Htmlable | null
{
$icon = $this->evaluate($this->trueIcon);
if ($icon === false) {
return null;
}
return $icon
?? FilamentIcon::resolve(TablesIconAlias::COLUMNS_ICON_COLUMN_TRUE)
?? Heroicon::OutlinedCheckCircle;
}
public function isBoolean(): bool
{
if (blank($this->isBoolean)) {
$this->isBoolean = $this->getRecord()?->hasCast($this->getName(), ['bool', 'boolean']);
}
return (bool) $this->evaluate($this->isBoolean);
}
public function isListWithLineBreaks(): bool
{
return (bool) $this->evaluate($this->isListWithLineBreaks);
}
public function toEmbeddedHtml(): string
{
$state = $this->getState();
if ($state instanceof Collection) {
$state = $state->all();
}
$alignment = $this->getAlignment();
$attributes = $this->getExtraAttributeBag()
->class([
'fi-ta-icon',
'fi-inline' => $this->isInline(),
($alignment instanceof Alignment) ? "fi-align-{$alignment->value}" : (is_string($alignment) ? $alignment : ''),
]);
if (blank($state)) {
$attributes = $attributes
->merge([
'x-tooltip' => filled($tooltip = $this->getEmptyTooltip())
? '{
content: ' . Js::from($tooltip) . ',
theme: $store.theme,
allowHTML: ' . Js::from($tooltip instanceof Htmlable) . ',
}'
: null,
], escape: false);
$placeholder = $this->getPlaceholder();
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php if (filled($placeholder)) { ?>
<p class="fi-ta-placeholder">
<?= e($placeholder) ?>
</p>
<?php } ?>
</div>
<?php return ob_get_clean();
}
$state = Arr::wrap($state);
$attributes = $attributes
->class([
'fi-ta-icon-has-line-breaks' => $this->isListWithLineBreaks(),
'fi-wrapped' => $this->canWrap(),
]);
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php foreach ($state as $stateItem) { ?>
<?php
$icon = $this->getIcon($stateItem);
if (blank($icon)) {
continue;
}
$color = $this->getColor($stateItem);
$size = $this->getSize($stateItem);
?>
<?= generate_icon_html($icon, attributes: (new ComponentAttributeBag)
->merge([
'x-tooltip' => filled($tooltip = $this->getTooltip($stateItem))
? '{
content: ' . Js::from($tooltip) . ',
theme: $store.theme,
allowHTML: ' . Js::from($tooltip instanceof Htmlable) . ',
}'
: null,
], escape: false)
->color(IconComponent::class, $color), size: $size ?? IconSize::Large)
->toHtml() ?>
<?php } ?>
</div>
<?php return ob_get_clean();
}
}
@@ -0,0 +1,541 @@
<?php
namespace Filament\Tables\Columns;
use Closure;
use Filament\Support\Components\Contracts\HasEmbeddedView;
use Filament\Support\Concerns\CanWrap;
use Filament\Support\Enums\Alignment;
use Filament\Support\Enums\TextSize;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Js;
use Illuminate\View\ComponentAttributeBag;
use League\Flysystem\UnableToCheckFileExistence;
use Throwable;
use function Filament\Support\generate_href_html;
class ImageColumn extends Column implements HasEmbeddedView
{
use CanWrap;
protected string | Closure | null $diskName = null;
protected int | string | Closure | null $imageHeight = null;
protected bool | Closure $isCircular = false;
protected bool | Closure $isSquare = false;
protected string | Closure | null $visibility = null;
protected int | string | Closure | null $imageWidth = null;
/**
* @var array<array<mixed> | Closure>
*/
protected array $extraImgAttributes = [];
protected string | Closure | null $defaultImageUrl = null;
protected bool | Closure $isStacked = false;
protected int | Closure | null $overlap = null;
protected int | Closure | null $ring = null;
protected int | Closure | null $limit = null;
protected bool | Closure $hasLimitedRemainingText = false;
protected TextSize | string | Closure | null $limitedRemainingTextSize = null;
protected bool | Closure $shouldCheckFileExistence = true;
public function disk(string | Closure | null $disk): static
{
$this->diskName = $disk;
return $this;
}
public function imageHeight(int | string | Closure | null $height): static
{
$this->imageHeight = $height;
return $this;
}
/**
* @deprecated Use `imageHeight()` instead.
*/
public function height(int | string | Closure | null $height): static
{
$this->imageHeight($height);
return $this;
}
public function circular(bool | Closure $condition = true): static
{
$this->isCircular = $condition;
return $this;
}
/**
* @deprecated Use `circular()` instead.
*/
public function rounded(bool | Closure $condition = true): static
{
return $this->circular($condition);
}
public function square(bool | Closure $condition = true): static
{
$this->isSquare = $condition;
return $this;
}
public function imageSize(int | string | Closure $size): static
{
$this->imageWidth($size);
$this->imageHeight($size);
return $this;
}
/**
* @deprecated Use `imageSize()` instead.
*/
public function size(int | string | Closure $size): static
{
$this->imageSize($size);
return $this;
}
public function visibility(string | Closure | null $visibility): static
{
$this->visibility = $visibility;
return $this;
}
public function imageWidth(int | string | Closure | null $width): static
{
$this->imageWidth = $width;
return $this;
}
public function getDisk(): Filesystem
{
return Storage::disk($this->getDiskName());
}
public function getDiskName(): string
{
$name = $this->evaluate($this->diskName);
if (filled($name)) {
return $name;
}
$defaultName = config('filament.default_filesystem_disk');
if (
($defaultName === 'public')
&& ($this->getCustomVisibility() === 'private')
) {
return 'local';
}
return $defaultName;
}
public function getImageHeight(): ?string
{
$height = $this->evaluate($this->imageHeight);
if ($height === null) {
return null;
}
if (is_int($height)) {
return "{$height}px";
}
return $height;
}
/**
* @deprecated Use `getImageHeight()` instead.
*/
public function getHeight(): ?string
{
return $this->getImageHeight();
}
public function defaultImageUrl(string | Closure | null $url): static
{
$this->defaultImageUrl = $url;
return $this;
}
public function getImageUrl(?string $state = null): ?string
{
if ((filter_var($state, FILTER_VALIDATE_URL) !== false) || str($state)->startsWith('data:')) {
return $state;
}
/** @var FilesystemAdapter $storage */
$storage = $this->getDisk();
if ($this->shouldCheckFileExistence()) {
try {
if (! $storage->exists($state)) {
return null;
}
} catch (UnableToCheckFileExistence $exception) {
return null;
}
}
if ($this->getVisibility() === 'private') {
try {
return $storage->temporaryUrl(
$state,
now()->addMinutes(30)->endOfHour(),
);
} catch (Throwable $exception) {
// This driver does not support creating temporary URLs.
}
}
return $storage->url($state);
}
public function getDefaultImageUrl(): ?string
{
return $this->evaluate($this->defaultImageUrl);
}
public function getVisibility(): string
{
$visibility = $this->getCustomVisibility();
if (filled($visibility)) {
return $visibility;
}
return ($this->getDiskName() === 'public') ? 'public' : 'private';
}
public function getCustomVisibility(): ?string
{
return $this->evaluate($this->visibility);
}
public function getImageWidth(): ?string
{
$width = $this->evaluate($this->imageWidth);
if ($width === null) {
return null;
}
if (is_int($width)) {
return "{$width}px";
}
return $width;
}
public function isCircular(): bool
{
return (bool) $this->evaluate($this->isCircular);
}
/**
* @deprecated Use `isCircular()` instead.
*/
public function isRounded(): bool
{
return $this->isCircular();
}
public function isSquare(): bool
{
return (bool) $this->evaluate($this->isSquare);
}
/**
* @param array<mixed> | Closure $attributes
*/
public function extraImgAttributes(array | Closure $attributes, bool $merge = false): static
{
if ($merge) {
$this->extraImgAttributes[] = $attributes;
} else {
$this->extraImgAttributes = [$attributes];
}
return $this;
}
/**
* @return array<mixed>
*/
public function getExtraImgAttributes(): array
{
$temporaryAttributeBag = new ComponentAttributeBag;
foreach ($this->extraImgAttributes as $extraImgAttributes) {
$temporaryAttributeBag = $temporaryAttributeBag->merge($this->evaluate($extraImgAttributes), escape: false);
}
return $temporaryAttributeBag->getAttributes();
}
public function getExtraImgAttributeBag(): ComponentAttributeBag
{
return new ComponentAttributeBag($this->getExtraImgAttributes());
}
public function stacked(bool | Closure $condition = true): static
{
$this->isStacked = $condition;
return $this;
}
public function isStacked(): bool
{
return (bool) $this->evaluate($this->isStacked);
}
public function overlap(int | Closure | null $overlap): static
{
$this->overlap = $overlap;
return $this;
}
public function getOverlap(): ?int
{
return $this->evaluate($this->overlap);
}
public function ring(int | Closure | null $ring): static
{
$this->ring = $ring;
return $this;
}
public function getRing(): ?int
{
return $this->evaluate($this->ring);
}
public function limit(int | Closure | null $limit = 3): static
{
$this->limit = $limit;
return $this;
}
public function getLimit(): ?int
{
return $this->evaluate($this->limit);
}
public function limitedRemainingText(bool | Closure $condition = true, TextSize | string | Closure | null $size = null): static
{
$this->hasLimitedRemainingText = $condition;
$this->limitedRemainingTextSize($size);
return $this;
}
public function hasLimitedRemainingText(): bool
{
return (bool) $this->evaluate($this->hasLimitedRemainingText);
}
public function limitedRemainingTextSize(TextSize | string | Closure | null $size): static
{
$this->limitedRemainingTextSize = $size;
return $this;
}
public function getLimitedRemainingTextSize(): TextSize | string | null
{
$size = $this->evaluate($this->limitedRemainingTextSize);
if (blank($size)) {
return null;
}
if (is_string($size)) {
$size = TextSize::tryFrom($size) ?? $size;
}
return $size;
}
public function checkFileExistence(bool | Closure $condition = true): static
{
$this->shouldCheckFileExistence = $condition;
return $this;
}
public function shouldCheckFileExistence(): bool
{
return (bool) $this->evaluate($this->shouldCheckFileExistence);
}
public function toEmbeddedHtml(): string
{
$state = $this->getState();
if ($state instanceof Collection) {
$state = $state->all();
}
$alignment = $this->getAlignment();
$attributes = $this->getExtraAttributeBag()
->class([
'fi-ta-image',
'fi-inline' => $this->isInline(),
($alignment instanceof Alignment) ? "fi-align-{$alignment->value}" : (is_string($alignment) ? $alignment : ''),
]);
$defaultImageUrl = $this->getDefaultImageUrl();
if (blank($state) && filled($defaultImageUrl)) {
$state = [null];
}
if (blank($state)) {
$attributes = $attributes
->merge([
'x-tooltip' => filled($tooltip = $this->getEmptyTooltip())
? '{
content: ' . Js::from($tooltip) . ',
theme: $store.theme,
allowHTML: ' . Js::from($tooltip instanceof Htmlable) . ',
}'
: null,
], escape: false);
$placeholder = $this->getPlaceholder();
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php if (filled($placeholder)) { ?>
<p class="fi-ta-placeholder">
<?= e($placeholder) ?>
</p>
<?php } ?>
</div>
<?php return ob_get_clean();
}
$state = Arr::wrap($state);
$stateCount = count($state);
$limit = $this->getLimit() ?? $stateCount;
$stateOverLimitCount = ($limit && ($stateCount > $limit))
? ($stateCount - $limit)
: 0;
if ($stateOverLimitCount) {
$state = array_slice($state, 0, $limit);
}
$isCircular = $this->isCircular();
$isSquare = $this->isSquare();
$isStacked = $this->isStacked();
$hasLimitedRemainingText = $stateOverLimitCount && $this->hasLimitedRemainingText();
$limitedRemainingTextSize = $this->getLimitedRemainingTextSize();
$height = $this->getImageHeight() ?? ($isStacked ? '2rem' : '2.5rem');
$width = $this->getImageWidth() ?? (($isCircular || $isSquare) ? $height : null);
$attributes = $attributes
->class([
'fi-circular' => $isCircular,
'fi-wrapped' => $this->canWrap(),
'fi-stacked' => $isStacked,
($isStacked && is_int($ring = $this->getRing())) ? "fi-ta-image-ring fi-ta-image-ring-{$ring}" : '',
($isStacked && ($overlap = ($this->getOverlap() ?? 2))) ? "fi-ta-image-overlap-{$overlap}" : '',
]);
$shouldOpenUrlInNewTab = $this->shouldOpenUrlInNewTab();
$formatState = function (mixed $stateItem) use ($defaultImageUrl, $width, $height, $shouldOpenUrlInNewTab): string {
$item = '<img ' . $this->getExtraImgAttributeBag()
->merge([
'src' => filled($stateItem) ? ($this->getImageUrl($stateItem) ?? $defaultImageUrl) : $defaultImageUrl,
'x-tooltip' => filled($tooltip = $this->getTooltip($stateItem))
? '{
content: ' . Js::from($tooltip) . ',
theme: $store.theme,
allowHTML: ' . Js::from($tooltip instanceof Htmlable) . ',
}'
: null,
], escape: false)
->style([
"height: {$height}" => $height,
"width: {$width}" => $width,
])
->toHtml()
. ' />';
if (filled($url = $this->getUrl($stateItem))) {
$item = '<a ' . generate_href_html($url, $shouldOpenUrlInNewTab)->toHtml() . '>' . $item . '</a>';
}
return $item;
};
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php foreach ($state as $stateItem) { ?>
<?= $formatState($stateItem) ?>
<?php } ?>
<?php if ($hasLimitedRemainingText) { ?>
<div <?= (new ComponentAttributeBag)
->class([
'fi-ta-image-limited-remaining-text',
(($limitedRemainingTextSize instanceof TextSize) ? "fi-size-{$limitedRemainingTextSize->value}" : $limitedRemainingTextSize) => $limitedRemainingTextSize,
])
->style([
"height: {$height}" => $height,
"width: {$width}" => $width,
])
->toHtml() ?>>
+<?= $stateOverLimitCount ?>
</div>
<?php } ?>
</div>
<?php return ob_get_clean();
}
}
@@ -0,0 +1,189 @@
<?php
namespace Filament\Tables\Columns\Layout;
use Closure;
use Filament\Support\Components\ViewComponent;
use Filament\Support\Concerns\CanGrow;
use Filament\Support\Concerns\CanSpanColumns;
use Filament\Support\Concerns\HasExtraAttributes;
use Filament\Tables\Columns\Column;
use Filament\Tables\Columns\Concerns\BelongsToLayout;
use Filament\Tables\Columns\Concerns\BelongsToTable;
use Filament\Tables\Columns\Concerns\CanBeHidden;
use Filament\Tables\Columns\Concerns\HasRecord;
use Filament\Tables\Columns\Concerns\HasRowLoopObject;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\HtmlString;
use Illuminate\View\ComponentAttributeBag;
use LogicException;
class Component extends ViewComponent
{
use BelongsToLayout;
use BelongsToTable;
use CanBeHidden;
use CanGrow;
use CanSpanColumns;
use HasExtraAttributes;
use HasRecord;
use HasRowLoopObject;
protected string $evaluationIdentifier = 'layout';
protected string $viewIdentifier = 'layout';
/**
* @var array<Column | Component> | Closure
*/
protected array | Closure $components = [];
protected bool $isCollapsible = false;
protected bool | Closure $isCollapsed = true;
/**
* @param array<Column | Component> | Closure $schema
*/
public function schema(array | Closure $schema): static
{
$this->components($schema);
return $this;
}
/**
* @param array<Column | Component> | Closure $components
*/
public function components(array | Closure $components): static
{
$this->components = $components;
return $this;
}
public function collapsible(bool $condition = true): static
{
$this->isCollapsible = $condition;
return $this;
}
public function collapsed(bool | Closure $condition = true): static
{
$this->collapsible();
$this->isCollapsed = $condition;
return $this;
}
public function isCollapsed(): bool
{
return (bool) $this->evaluate($this->isCollapsed);
}
/**
* @return array<string, Column>
*/
public function getColumns(): array
{
$columns = [];
foreach ($this->getComponents() as $component) {
if ($component instanceof Column) {
$columns[$component->getName()] = $component;
continue;
}
$columns = [
...$columns,
...$component->getColumns(),
];
}
return $columns;
}
/**
* @return array<Column | Component>
*/
public function getComponents(): array
{
return array_map(function (Component | Column $component): Component | Column {
return $component->layout($this);
}, $this->evaluate($this->components));
}
public function getTable(): Table
{
return $this->table ?? $this->getLayout()?->getTable() ?? throw new LogicException('The column layout component is not mounted to a table.');
}
public function isCollapsible(): bool
{
return $this->isCollapsible;
}
/**
* @return array<mixed>
*/
protected function resolveDefaultClosureDependencyForEvaluationByName(string $parameterName): array
{
return match ($parameterName) {
'livewire' => [$this->getLivewire()],
'record' => [$this->getRecord()],
'rowLoop' => [$this->getRowLoop()],
'table' => [$this->getTable()],
default => parent::resolveDefaultClosureDependencyForEvaluationByName($parameterName),
};
}
/**
* @return array<mixed>
*/
protected function resolveDefaultClosureDependencyForEvaluationByType(string $parameterType): array
{
$record = $this->getRecord();
if (! $record) {
return parent::resolveDefaultClosureDependencyForEvaluationByType($parameterType);
}
if (! ($record instanceof Model)) {
return parent::resolveDefaultClosureDependencyForEvaluationByType($parameterType);
}
return match ($parameterType) {
Model::class, $record::class => [$record],
default => parent::resolveDefaultClosureDependencyForEvaluationByType($parameterType),
};
}
public function renderInLayout(): ?HtmlString
{
if ($this->isHidden()) {
return null;
}
$attributes = (new ComponentAttributeBag)
->gridColumn(
$this->getColumnSpan(),
$this->getColumnStart(),
)
->class([
'fi-growable' => $this->canGrow(),
(filled($hiddenFrom = $this->getHiddenFrom()) ? "{$hiddenFrom}:fi-hidden" : ''),
(filled($visibleFrom = $this->getVisibleFrom()) ? "{$visibleFrom}:fi-visible" : ''),
]);
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?= $this->toHtml() ?>
</div>
<?php return new HtmlString(ob_get_clean());
}
}
@@ -0,0 +1,90 @@
<?php
namespace Filament\Tables\Columns\Layout;
use Filament\Support\Components\Contracts\HasEmbeddedView;
class Grid extends Component implements HasEmbeddedView
{
/**
* @var array<string, int | null> | null
*/
protected ?array $columns = null;
/**
* @param array<string, ?int> | int | null $columns
*/
final public function __construct(array | int | null $columns = 2)
{
$this->columns($columns);
}
/**
* @param array<string, ?int> | int | null $columns
*/
public static function make(array | int | null $columns = 2): static
{
$static = app(static::class, ['columns' => $columns]);
$static->configure();
return $static;
}
/**
* @param array<string, ?int> | int | null $columns
*/
public function columns(array | int | null $columns = 2): static
{
if (! is_array($columns)) {
$columns = [
'lg' => $columns,
];
}
$this->columns = [
...($this->columns ?? []),
...$columns,
];
return $this;
}
/**
* @return array<string, int | null> | null
*/
public function getGridColumns(): ?array
{
return $this->columns;
}
public function toEmbeddedHtml(): string
{
$columns = $this->getGridColumns();
$attributes = $this->getExtraAttributeBag()
->grid($columns)
->class([
'fi-ta-grid',
(($columns['default'] ?? 1) === 1) ? 'fi-gap-sm' : 'fi-gap-lg',
($columns['sm'] ?? null) ? (($columns['sm'] === 1) ? 'sm:fi-gap-sm' : 'sm:fi-gap-lg') : null,
($columns['md'] ?? null) ? (($columns['md'] === 1) ? 'md:fi-gap-sm' : 'md:fi-gap-lg') : null,
($columns['lg'] ?? null) ? (($columns['lg'] === 1) ? 'lg:fi-gap-sm' : 'lg:fi-gap-lg') : null,
($columns['xl'] ?? null) ? (($columns['xl'] === 1) ? 'xl:fi-gap-sm' : 'xl:fi-gap-lg') : null,
($columns['2xl'] ?? null) ? (($columns['2xl'] === 1) ? '2xl:fi-gap-sm' : '2xl:fi-gap-lg') : null,
]);
$record = $this->getRecord();
$recordKey = $this->getRecordKey();
$rowLoop = $this->getRowLoop();
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php foreach ($this->getComponents() as $component) { ?>
<?= $component->record($record)->recordKey($recordKey)->rowLoop($rowLoop)->renderInLayout() ?>
<?php } ?>
</div>
<?php return ob_get_clean();
}
}
@@ -0,0 +1,49 @@
<?php
namespace Filament\Tables\Columns\Layout;
use Closure;
use Filament\Support\Components\Contracts\HasEmbeddedView;
use Filament\Tables\Columns\Column;
class Panel extends Component implements HasEmbeddedView
{
/**
* @param array<Column | Component> | Closure $schema
*/
final public function __construct(array | Closure $schema)
{
$this->schema($schema);
}
/**
* @param array<Column | Component> | Closure $schema
*/
public static function make(array | Closure $schema): static
{
$static = app(static::class, ['schema' => $schema]);
$static->configure();
return $static;
}
public function toEmbeddedHtml(): string
{
$attributes = $this->getExtraAttributeBag()
->class(['fi-ta-panel']);
$record = $this->getRecord();
$recordKey = $this->getRecordKey();
$rowLoop = $this->getRowLoop();
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php foreach ($this->getComponents() as $component) { ?>
<?= $component->record($record)->recordKey($recordKey)->rowLoop($rowLoop)->renderInLayout() ?>
<?php } ?>
</div>
<?php return ob_get_clean();
}
}
@@ -0,0 +1,55 @@
<?php
namespace Filament\Tables\Columns\Layout;
use Closure;
use Filament\Support\Components\Contracts\HasEmbeddedView;
use Filament\Support\Concerns\HasFromBreakpoint;
use Filament\Tables\Columns\Column;
class Split extends Component implements HasEmbeddedView
{
use HasFromBreakpoint;
/**
* @param array<Column | Component> | Closure $schema
*/
final public function __construct(array | Closure $schema)
{
$this->schema($schema);
}
/**
* @param array<Column | Component> | Closure $schema
*/
public static function make(array | Closure $schema): static
{
$static = app(static::class, ['schema' => $schema]);
$static->configure();
return $static;
}
public function toEmbeddedHtml(): string
{
$attributes = $this->getExtraAttributeBag()
->class([
'fi-ta-split',
filled($fromBreakpoint = $this->getFromBreakpoint()) ? "{$fromBreakpoint}:fi-ta-split" : 'default:fi-ta-split',
]);
$record = $this->getRecord();
$recordKey = $this->getRecordKey();
$rowLoop = $this->getRowLoop();
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php foreach ($this->getComponents() as $component) { ?>
<?= $component->record($record)->recordKey($recordKey)->rowLoop($rowLoop)->renderInLayout() ?>
<?php } ?>
</div>
<?php return ob_get_clean();
}
}
@@ -0,0 +1,70 @@
<?php
namespace Filament\Tables\Columns\Layout;
use Closure;
use Filament\Support\Components\Contracts\HasEmbeddedView;
use Filament\Support\Concerns\HasAlignment;
use Filament\Support\Enums\Alignment;
use Filament\Tables\Columns\Column;
use Filament\Tables\Columns\Concerns\HasSpace;
class Stack extends Component implements HasEmbeddedView
{
use HasAlignment;
use HasSpace;
/**
* @param array<Column | Component> | Closure $schema
*/
final public function __construct(array | Closure $schema)
{
$this->schema($schema);
}
/**
* @param array<Column | Component> | Closure $schema
*/
public static function make(array | Closure $schema): static
{
$static = app(static::class, ['schema' => $schema]);
$static->configure();
return $static;
}
public function toEmbeddedHtml(): string
{
$alignment = $this->getAlignment() ?? Alignment::Start;
if (! $alignment instanceof Alignment) {
$alignment = filled($alignment) ? (Alignment::tryFrom($alignment) ?? $alignment) : null;
}
$attributes = $this->getExtraAttributeBag()
->class([
'fi-ta-stack',
($alignment instanceof Alignment) ? "fi-align-{$alignment->value}" : $alignment,
match ($space = $this->getSpace()) {
1 => 'fi-gap-sm',
2 => 'fi-gap-md',
3 => 'fi-gap-lg',
default => $space,
},
]);
$record = $this->getRecord();
$recordKey = $this->getRecordKey();
$rowLoop = $this->getRowLoop();
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php foreach ($this->getComponents() as $component) { ?>
<?= $component->record($record)->recordKey($recordKey)->rowLoop($rowLoop)->renderInLayout() ?>
<?php } ?>
</div>
<?php return ob_get_clean();
}
}
@@ -0,0 +1,25 @@
<?php
namespace Filament\Tables\Columns\Layout;
class View extends Component
{
/**
* @param view-string $view
*/
final public function __construct(string $view)
{
$this->view($view);
}
/**
* @param view-string $view
*/
public static function make(string $view): static
{
$static = app(static::class, ['view' => $view]);
$static->configure();
return $static;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
<?php
namespace Filament\Tables\Columns\Summarizers;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Str;
class Average extends Summarizer
{
protected ?string $selectAlias = null;
protected function setUp(): void
{
parent::setUp();
$this->numeric();
}
public function summarize(Builder $query, string $attribute): int | float | null
{
return $query->avg($attribute);
}
/**
* @return array<string, string>
*/
public function getSelectStatements(string $column): array
{
$column = $this->getQuery()->getGrammar()->wrap($column);
return [
$this->getSelectAlias() => "avg({$column})",
];
}
public function getSelectedState(): int | float | null
{
if (! array_key_exists($this->selectAlias, $this->selectedState)) {
return null;
}
return $this->selectedState[$this->getSelectAlias()];
}
public function selectAlias(?string $alias): static
{
$this->selectAlias = $alias;
return $this;
}
public function getSelectAlias(): string
{
return $this->selectAlias ??= Str::random();
}
public function getDefaultLabel(): ?string
{
return __('filament-tables::table.summary.summarizers.average.label');
}
}
@@ -0,0 +1,34 @@
<?php
namespace Filament\Tables\Columns\Summarizers\Concerns;
use Filament\Tables\Columns\Column;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
trait BelongsToColumn
{
protected Column $column;
public function column(Column $column): static
{
$this->column = $column;
return $this;
}
public function getColumn(): Column
{
return $this->column;
}
public function getTable(): Table
{
return $this->getColumn()->getTable();
}
public function getLivewire(): HasTable
{
return $this->getTable()->getLivewire();
}
}
@@ -0,0 +1,90 @@
<?php
namespace Filament\Tables\Columns\Summarizers\Concerns;
use Closure;
use Filament\Tables\Contracts\HasTable;
use Illuminate\Support\Arr;
trait CanBeHidden
{
protected bool | Closure $isHidden = false;
protected bool | Closure $isVisible = true;
/**
* @var array<string, bool>
*/
protected array $visibilityCache = [];
public function hidden(bool | Closure $condition = true): static
{
$this->isHidden = $condition;
return $this;
}
/**
* @param string | array<string> $livewireComponents
*/
public function hiddenOn(string | array $livewireComponents): static
{
$this->hidden(static function (HasTable $livewire) use ($livewireComponents): bool {
foreach (Arr::wrap($livewireComponents) as $livewireComponent) {
if ($livewire instanceof $livewireComponent) {
return true;
}
}
return false;
});
return $this;
}
public function visible(bool | Closure $condition = true): static
{
$this->isVisible = $condition;
return $this;
}
/**
* @param string | array<string> $livewireComponents
*/
public function visibleOn(string | array $livewireComponents): static
{
$this->visible(static function (HasTable $livewire) use ($livewireComponents): bool {
foreach (Arr::wrap($livewireComponents) as $livewireComponent) {
if ($livewire instanceof $livewireComponent) {
return true;
}
}
return false;
});
return $this;
}
public function isHidden(): bool
{
$query = $this->getQuery();
$querySql = $query ? md5($query->toRawSql()) : '';
if (array_key_exists($querySql, $this->visibilityCache)) {
return $this->visibilityCache[$querySql];
}
if ($this->evaluate($this->isHidden)) {
return $this->visibilityCache[$querySql] = true;
}
return $this->visibilityCache[$querySql] = ! $this->evaluate($this->isVisible);
}
public function isVisible(): bool
{
return ! $this->isHidden();
}
}
@@ -0,0 +1,232 @@
<?php
namespace Filament\Tables\Columns\Summarizers\Concerns;
use BackedEnum;
use Closure;
use Filament\Support\Concerns\CanConfigureCommonMark;
use Filament\Support\Enums\ArgumentValue;
use Filament\Tables\Columns\Summarizers\Summarizer;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Number;
use Illuminate\Support\Str;
trait CanFormatState
{
use CanConfigureCommonMark;
protected ?Closure $formatStateUsing = null;
protected string | Closure | null $placeholder = null;
protected string | Htmlable | Closure | null $prefix = null;
protected string | Htmlable | Closure | null $suffix = null;
protected bool | Closure $isHtml = false;
protected bool | Closure $isMarkdown = false;
public function formatStateUsing(?Closure $callback): static
{
$this->formatStateUsing = $callback;
return $this;
}
public function limit(int $length = 100, ?string $end = '...'): static
{
$this->formatStateUsing(static function ($state) use ($end, $length): ?string {
if (blank($state)) {
return null;
}
return Str::limit(strval($state), $length, $end ?? '');
});
return $this;
}
public function money(string | BackedEnum | Closure | null $currency = null, int $divideBy = 0, string | BackedEnum | Closure | null $locale = null, int | Closure | null $decimalPlaces = null): static
{
$this->formatStateUsing(static function ($state, Summarizer $summarizer) use ($currency, $divideBy, $locale, $decimalPlaces): ?string {
if (blank($state)) {
return null;
}
if (! is_numeric($state)) {
return $state;
}
$currency = $summarizer->evaluate($currency) ?? $summarizer->getTable()->getDefaultCurrency();
$locale = $summarizer->evaluate($locale) ?? $summarizer->getTable()->getDefaultNumberLocale() ?? config('app.locale');
$decimalPlaces = $summarizer->evaluate($decimalPlaces);
if ($divideBy) {
$state /= $divideBy;
}
if ($currency instanceof BackedEnum) {
$currency = (string) $currency->value;
}
if ($locale instanceof BackedEnum) {
$locale = (string) $locale->value;
}
return Number::currency($state, $currency, $locale, $decimalPlaces);
});
return $this;
}
public function numeric(int | Closure | null $decimalPlaces = null, string | Closure | null | ArgumentValue $decimalSeparator = ArgumentValue::Default, string | Closure | null | ArgumentValue $thousandsSeparator = ArgumentValue::Default, int | Closure | null $maxDecimalPlaces = null, string | Closure | null $locale = null): static
{
$this->formatStateUsing(static function ($state, Summarizer $summarizer) use ($decimalPlaces, $decimalSeparator, $locale, $maxDecimalPlaces, $thousandsSeparator): ?string {
if (blank($state)) {
return null;
}
if (! is_numeric($state)) {
return $state;
}
$decimalPlaces = $summarizer->evaluate($decimalPlaces);
$decimalSeparator = $summarizer->evaluate($decimalSeparator);
$thousandsSeparator = $summarizer->evaluate($thousandsSeparator);
if (
($decimalSeparator !== ArgumentValue::Default) ||
($thousandsSeparator !== ArgumentValue::Default)
) {
return number_format(
$state,
$decimalPlaces,
$decimalSeparator === ArgumentValue::Default ? '.' : $decimalSeparator,
$thousandsSeparator === ArgumentValue::Default ? ',' : $thousandsSeparator,
);
}
$locale = $summarizer->evaluate($locale) ?? $summarizer->getTable()->getDefaultNumberLocale() ?? config('app.locale');
return Number::format($state, $decimalPlaces, $summarizer->evaluate($maxDecimalPlaces), locale: $locale);
});
return $this;
}
public function placeholder(string | Closure | null $placeholder): static
{
$this->placeholder = $placeholder;
return $this;
}
public function markdown(bool | Closure $condition = true): static
{
$this->isMarkdown = $condition;
return $this;
}
public function prefix(string | Htmlable | Closure | null $prefix): static
{
$this->prefix = $prefix;
return $this;
}
public function suffix(string | Htmlable | Closure | null $suffix): static
{
$this->suffix = $suffix;
return $this;
}
public function html(bool | Closure $condition = true): static
{
$this->isHtml = $condition;
return $this;
}
public function formatState(mixed $state): mixed
{
$isHtml = $this->isHtml();
$state = $this->evaluate($this->formatStateUsing ?? $state, [
'state' => $state,
]);
if ($isHtml) {
if ($this->isMarkdown()) {
$state = Str::markdown($state, $this->getCommonMarkOptions(), $this->getCommonMarkExtensions());
}
$state = Str::sanitizeHtml($state);
}
if ($state instanceof Htmlable) {
$isHtml = true;
$state = $state->toHtml();
}
$prefix = $this->getPrefix();
$suffix = $this->getSuffix();
if (
(($prefix instanceof Htmlable) || ($suffix instanceof Htmlable)) &&
(! $isHtml)
) {
$isHtml = true;
$state = e($state);
}
if (filled($prefix)) {
if ($prefix instanceof Htmlable) {
$prefix = $prefix->toHtml();
} elseif ($isHtml) {
$prefix = e($prefix);
}
$state = $prefix . $state;
}
if (filled($suffix)) {
if ($suffix instanceof Htmlable) {
$suffix = $suffix->toHtml();
} elseif ($isHtml) {
$suffix = e($suffix);
}
$state .= $suffix;
}
if (blank($state)) {
$state = $this->evaluate($this->placeholder);
}
return $isHtml ? new HtmlString($state) : $state;
}
public function isHtml(): bool
{
return $this->evaluate($this->isHtml) || $this->isMarkdown();
}
public function getPrefix(): string | Htmlable | null
{
return $this->evaluate($this->prefix);
}
public function getSuffix(): string | Htmlable | null
{
return $this->evaluate($this->suffix);
}
public function isMarkdown(): bool
{
return (bool) $this->evaluate($this->isMarkdown);
}
}
@@ -0,0 +1,46 @@
<?php
namespace Filament\Tables\Columns\Summarizers\Concerns;
use Closure;
trait HasLabel
{
protected string | Closure | null $label = null;
protected bool $shouldTranslateLabel = false;
public function label(string | Closure | null $label): static
{
$this->label = $label;
return $this;
}
public function translateLabel(bool $shouldTranslateLabel = true): static
{
$this->shouldTranslateLabel = $shouldTranslateLabel;
return $this;
}
public function getLabel(): ?string
{
$label = $this->evaluate($this->label);
if ($label === null) {
return $this->getDefaultLabel();
}
if (blank($label)) {
return null;
}
return $this->shouldTranslateLabel ? __($label) : $label;
}
public function getDefaultLabel(): ?string
{
return null;
}
}
@@ -0,0 +1,36 @@
<?php
namespace Filament\Tables\Columns\Summarizers\Concerns;
use Closure;
use Illuminate\Database\Eloquent\Builder;
trait InteractsWithTableQuery
{
protected ?Closure $modifyQueryUsing = null;
protected ?Builder $query = null;
public function query(Builder | Closure | null $query): static
{
if ($query instanceof Builder || ($query === null)) {
$this->query = $query;
}
if ($query instanceof Closure || ($query === null)) {
$this->modifyQueryUsing = $query;
}
return $this;
}
public function getQuery(): ?Builder
{
return $this->query;
}
public function hasQueryModification(): bool
{
return $this->modifyQueryUsing instanceof Closure;
}
}
@@ -0,0 +1,160 @@
<?php
namespace Filament\Tables\Columns\Summarizers;
use Filament\Support\Enums\IconSize;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\View\Components\Columns\Summarizers\CountComponent\IconComponent;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Str;
use Illuminate\View\ComponentAttributeBag;
use LogicException;
use function Filament\Support\generate_icon_html;
class Count extends Summarizer
{
protected bool $hasIcons = false;
protected ?string $selectAlias = null;
protected function setUp(): void
{
parent::setUp();
$this->numeric();
}
/**
* @return int | float | array<string, array<string, int>> | null
*/
public function summarize(Builder $query, string $attribute): int | float | array | null
{
if (! $this->hasIcons) {
return $query->count();
}
$column = $this->getColumn();
if (! ($column instanceof IconColumn)) {
throw new LogicException("The [{$column->getName()}] column must be an IconColumn to show an icon count summary.");
}
$state = [];
foreach ($query->clone()->distinct()->pluck($attribute) as $value) {
$column->record($this->getQuery()->getModel()->setKeyName($attribute)->setAttribute($attribute, $value));
$column->clearCachedState();
$columnState = $column->getState();
$column->clearCachedState();
$color = json_encode($column->getColor($columnState));
$icon = $column->getIcon($columnState);
$iconKey = serialize($icon);
$state[$color] ??= [];
$state[$color][$iconKey] ??= 0;
$state[$color][$iconKey] += $query->clone()->where($attribute, $value)->count();
}
return $state;
}
/**
* @return array<string, string>
*/
public function getSelectStatements(string $column): array
{
if ($this->hasIcons) {
return [];
}
$column = $this->getQuery()->getGrammar()->wrap($column);
return [
$this->getSelectAlias() => "count({$column})",
];
}
public function getSelectedState(): int | float | null
{
if (! array_key_exists($this->selectAlias, $this->selectedState)) {
return null;
}
return $this->selectedState[$this->getSelectAlias()];
}
public function selectAlias(?string $alias): static
{
$this->selectAlias = $alias;
return $this;
}
public function getSelectAlias(): string
{
return $this->selectAlias ??= Str::random();
}
public function icons(bool $condition = true): static
{
$this->hasIcons = $condition;
return $this;
}
public function getDefaultLabel(): ?string
{
return $this->hasIcons ? null : __('filament-tables::table.summary.summarizers.count.label');
}
public function hasIcons(): bool
{
return $this->hasIcons;
}
public function toEmbeddedHtml(): string
{
if ($this->hasIcons()) {
$attributes = $this->getExtraAttributeBag()
->class(['fi-ta-icon-count-summary']);
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php if (filled($label = $this->getLabel())) { ?>
<span class="fi-ta-icon-count-summary-label">
<?= $label ?>
</span>
<?php } ?>
<?php if ($state = $this->getState()) { ?>
<ul>
<?php foreach ($state as $color => $icons) { ?>
<?php $color = json_decode($color); ?>
<?php foreach ($icons as $icon => $count) { ?>
<li>
<span>
<?= $count ?>
</span>
<?= generate_icon_html(
unserialize($icon),
attributes: (new ComponentAttributeBag)->color(IconComponent::class, $color),
size: IconSize::Large,
)->toHtml() ?>
</li>
<?php } ?>
<?php } ?>
</ul>
<?php } ?>
</div>
<?php return ob_get_clean();
}
return parent::toEmbeddedHtml();
}
}
@@ -0,0 +1,169 @@
<?php
namespace Filament\Tables\Columns\Summarizers;
use Carbon\CarbonImmutable;
use Closure;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Str;
class Range extends Summarizer
{
protected bool | Closure $shouldExcludeNull = true;
/**
* @return array{0: mixed, 1: mixed}
*/
public function summarize(Builder $query, string $attribute): array
{
if ($this->shouldExcludeNull()) {
$query->whereNotNull($attribute);
}
$minSelectAlias = Str::random();
$maxSelectAlias = Str::random();
$column = $query->getGrammar()->wrap($attribute);
$state = $query->selectRaw("min({$column}) as \"{$minSelectAlias}\", max({$column}) as \"{$maxSelectAlias}\"")->get()[0];
return [$state->{$minSelectAlias}, $state->{$maxSelectAlias}];
}
public function excludeNull(bool | Closure $condition = true): static
{
$this->shouldExcludeNull = $condition;
return $this;
}
public function minimalDateTimeDifference(): static
{
$this->formatStateUsing(static function (Range $summarizer, array $state): array {
if (blank($state[1])) {
unset($state[1]);
}
if (blank($state[0])) {
unset($state[0]);
}
if (count($state) !== 2) {
return $state;
}
$originalFrom = CarbonImmutable::make($state[0]);
$originalTo = CarbonImmutable::make($state[1]);
$fromDate = $originalFrom->translatedFormat($defaultDateDisplayFormat = $summarizer->getTable()->getDefaultDateDisplayFormat());
$toDate = $originalTo->translatedFormat($defaultDateDisplayFormat);
if ($fromDate !== $toDate) {
return [$fromDate, $toDate];
}
$fromDateTime = $originalFrom->translatedFormat($defaultDateTimeDisplayFormat = $summarizer->getTable()->getDefaultDateTimeDisplayFormat());
$toDateTime = $originalTo->translatedFormat($defaultDateTimeDisplayFormat);
if ($fromDateTime === $toDateTime) {
return [$fromDateTime];
}
return [$fromDateTime, $toDateTime];
});
return $this;
}
public function minimalTextualDifference(): static
{
$this->formatStateUsing(static function (array $state): array {
$originalFrom = trim(strval($state[0]));
$originalTo = trim(strval($state[1]));
if (($originalFrom === $originalTo) || blank($originalTo)) {
unset($state[1]);
}
if (blank($originalFrom)) {
unset($state[0]);
}
if (count($state) !== 2) {
return $state;
}
$originalFromCharacters = str_split($originalFrom);
$originalToCharacters = str_split($originalTo);
$from = '';
$to = '';
$isFromLonger = (count($originalFromCharacters) > count($originalToCharacters));
$characterIndex = 0;
foreach (($isFromLonger ? $originalToCharacters : $originalFromCharacters) as $characterIndex => $character) {
$from .= ($isFromLonger ? $originalFromCharacters[$characterIndex] : $character);
$to .= ($isFromLonger ? $character : $originalToCharacters[$characterIndex]);
if (Str::lower($from) !== Str::lower($to)) {
break;
}
}
if ($from !== $to) {
return [$from, $to];
}
$characterIndex++;
if ($isFromLonger) {
$from .= ($originalFromCharacters[$characterIndex] ?? '');
} else {
$to .= ($originalToCharacters[$characterIndex] ?? '');
}
return [$from, $to];
});
return $this;
}
public function shouldExcludeNull(): bool
{
return (bool) $this->evaluate($this->shouldExcludeNull);
}
public function toEmbeddedHtml(): string
{
$attributes = $this->getExtraAttributeBag()
->class(['fi-ta-range-summary']);
$state = $this->formatState($this->getState());
$from = $state[0] ?? null;
$to = $state[1] ?? null;
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php if (filled($label = $this->getLabel())) { ?>
<span class="fi-ta-range-summary-label">
<?= $label ?>
</span>
<?php } ?>
<?php if (filled($from) || filled($to)) { ?>
<span>
<?= $from ?>
<?= (filled($from) && filled($to)) ? '-' : '' ?>
<?= $to ?>
</span>
<?php } ?>
</div>
<?php return ob_get_clean();
}
}
@@ -0,0 +1,61 @@
<?php
namespace Filament\Tables\Columns\Summarizers;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Str;
class Sum extends Summarizer
{
protected ?string $selectAlias = null;
protected function setUp(): void
{
parent::setUp();
$this->numeric();
}
public function summarize(Builder $query, string $attribute): int | float | null
{
return $query->sum($attribute);
}
/**
* @return array<string, string>
*/
public function getSelectStatements(string $column): array
{
$column = $this->getQuery()->getGrammar()->wrap($column);
return [
$this->getSelectAlias() => "sum({$column})",
];
}
public function getSelectedState(): int | float | null
{
if (! array_key_exists($this->selectAlias, $this->selectedState)) {
return null;
}
return $this->selectedState[$this->getSelectAlias()];
}
public function selectAlias(?string $alias): static
{
$this->selectAlias = $alias;
return $this;
}
public function getSelectAlias(): string
{
return $this->selectAlias ??= Str::random();
}
public function getDefaultLabel(): ?string
{
return __('filament-tables::table.summary.summarizers.sum.label');
}
}
@@ -0,0 +1,216 @@
<?php
namespace Filament\Tables\Columns\Summarizers;
use Closure;
use Filament\Support\Components\Contracts\HasEmbeddedView;
use Filament\Support\Components\ViewComponent;
use Filament\Support\Concerns\HasExtraAttributes;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Query\Builder;
class Summarizer extends ViewComponent implements HasEmbeddedView
{
use Concerns\BelongsToColumn;
use Concerns\CanBeHidden;
use Concerns\CanFormatState;
use Concerns\HasLabel;
use Concerns\InteractsWithTableQuery;
use HasExtraAttributes;
protected string $evaluationIdentifier = 'summarizer';
protected string $viewIdentifier = 'summarizer';
protected ?string $id = null;
/**
* @var array<string, mixed>
*/
protected array $selectedState = [];
protected ?Closure $using = null;
final public function __construct(?string $id = null)
{
$this->id($id);
}
public static function make(?string $id = null): static
{
$static = app(static::class, ['id' => $id]);
$static->configure();
return $static;
}
public function id(?string $id): static
{
$this->id = $id;
return $this;
}
public function using(?Closure $using): static
{
$this->using = $using;
return $this;
}
/**
* @param array<string, mixed> $state
*/
public function selectedState(array $state): static
{
$this->selectedState = $state;
return $this;
}
public function getState(): mixed
{
if (filled($state = $this->getSelectedState())) {
return $state;
}
$column = $this->getColumn();
$attribute = $column->getName();
$query = $this->getQuery()?->clone();
$hasRelationship = $query && $column->hasRelationship($query->getModel());
if ($this->hasQueryModification() && $hasRelationship) {
$baseQueryForModification = $query->toBase();
$this->evaluate($this->modifyQueryUsing, [
'attribute' => $attribute,
'query' => $baseQueryForModification,
]);
}
if ($hasRelationship) {
$relationship = $column->getRelationship($query->getModel());
$attribute = $column->getFullAttributeName($query->getModel());
$inverseRelationship = $column->getInverseRelationshipName($query->getModel());
$baseQuery = $query->toBase();
$query = $relationship->getQuery()->getModel()->newQuery()
->whereHas(
$inverseRelationship,
function (EloquentBuilder $relatedQuery) use ($baseQuery, $query): EloquentBuilder {
$relatedQuery->mergeConstraintsFrom($query);
if ($baseQuery->limit !== null) {
/** @var Collection $records */
$records = $this->getTable()->getRecords();
$relatedQuery->whereKey($records->modelKeys());
}
return $relatedQuery;
},
);
} elseif ($query && str($attribute)->startsWith('pivot.')) {
// https://github.com/filamentphp/filament/issues/12501
$pivotAttribute = (string) str($attribute)
->after('pivot.')
->prepend('pivot_');
$isPivotAttributeSelected = collect($query->getQuery()->getColumns())
->contains(fn (string $column): bool => str($column)->endsWith(" as {$pivotAttribute}"));
$attribute = $isPivotAttributeSelected ? $pivotAttribute : $attribute;
// Avoid duplicate columns in the subquery by selecting pivot columns individually.
if ($isPivotAttributeSelected) {
$query->getQuery()->columns = array_filter(
$query->getQuery()->columns,
fn (mixed $column): bool => $column !== "{$query->getQuery()->joins[0]->table}.*",
);
}
}
$asName = (string) str($query?->getModel()->getTable())->afterLast('.');
$query = $query?->getModel()->resolveConnection($query->getModel()->getConnectionName())
->table($query->toBase(), $asName);
if ($this->hasQueryModification() && ! $hasRelationship) {
$query = $this->evaluate($this->modifyQueryUsing, [
'attribute' => $attribute,
'query' => $query,
]) ?? $query;
}
if ($this->using !== null) {
return $this->evaluate($this->using, [
'attribute' => $attribute,
'query' => $query,
]);
}
return $this->summarize($query, $attribute);
}
public function getSelectedState(): mixed
{
return null;
}
public function summarize(Builder $query, string $attribute): mixed
{
return null;
}
/**
* @return array<string, string>
*/
public function getSelectStatements(string $column): array
{
return [];
}
public function getId(): ?string
{
return $this->id;
}
/**
* @return array<mixed>
*/
protected function resolveDefaultClosureDependencyForEvaluationByName(string $parameterName): array
{
return match ($parameterName) {
'livewire' => [$this->getLivewire()],
'table' => [$this->getTable()],
'query' => [$this->getQuery()],
default => parent::resolveDefaultClosureDependencyForEvaluationByName($parameterName),
};
}
public function toEmbeddedHtml(): string
{
$attributes = $this->getExtraAttributeBag()
->class(['fi-ta-text-summary']);
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php if (filled($label = $this->getLabel())) { ?>
<span class="fi-ta-text-summary-label">
<?= $label ?>
</span>
<?php } ?>
<span>
<?= $this->formatState($this->getState()) ?>
</span>
</div>
<?php return ob_get_clean();
}
}
@@ -0,0 +1,59 @@
<?php
namespace Filament\Tables\Columns\Summarizers;
use Closure;
use Illuminate\Database\Query\Builder;
class Values extends Summarizer
{
protected bool | Closure $isBulleted = true;
/**
* @return array<string, int>
*/
public function summarize(Builder $query, string $attribute): array
{
return $query->clone()->distinct()->pluck($attribute)->all();
}
public function bulleted(bool | Closure $condition = true): static
{
$this->isBulleted = $condition;
return $this;
}
public function isBulleted(): bool
{
return (bool) $this->evaluate($this->isBulleted);
}
public function toEmbeddedHtml(): string
{
$attributes = $this->getExtraAttributeBag()
->class(['fi-ta-values-summary']);
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php if (filled($label = $this->getLabel())) { ?>
<span class="fi-ta-values-summary-label">
<?= $label ?>
</span>
<?php } ?>
<?php if ($state = $this->getState()) { ?>
<ul <?= $this->isBulleted() ? 'class="fi-bulleted"' : '' ?>>
<?php foreach ($state as $stateItem) { ?>
<li>
<?= $this->formatState($stateItem) ?>
</li>
<?php } ?>
</ul>
<?php } ?>
</div>
<?php return ob_get_clean();
}
}
@@ -0,0 +1,28 @@
<?php
namespace Filament\Tables\Columns;
use Closure;
/**
* @deprecated Use `TextColumn` instead.
*/
class TagsColumn extends TextColumn
{
protected function setUp(): void
{
parent::setUp();
$this->badge();
}
/**
* @deprecated Use `limitList()` instead.
*/
public function limit(int | Closure | null $length = 3, string | Closure | null $end = null): static
{
$this->limitList($length);
return $this;
}
}
@@ -0,0 +1,549 @@
<?php
namespace Filament\Tables\Columns;
use Closure;
use Filament\Support\Components\Contracts\HasEmbeddedView;
use Filament\Support\Concerns\CanBeCopied;
use Filament\Support\Concerns\CanWrap;
use Filament\Support\Concerns\HasFontFamily;
use Filament\Support\Concerns\HasLineClamp;
use Filament\Support\Concerns\HasWeight;
use Filament\Support\Enums\Alignment;
use Filament\Support\Enums\FontFamily;
use Filament\Support\Enums\FontWeight;
use Filament\Support\Enums\IconPosition;
use Filament\Support\Enums\IconSize;
use Filament\Support\Enums\TextSize;
use Filament\Support\View\Components\BadgeComponent;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\View\Components\Columns\TextColumnComponent\ItemComponent;
use Filament\Tables\View\Components\Columns\TextColumnComponent\ItemComponent\IconComponent;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Js;
use Illuminate\View\ComponentAttributeBag;
use stdClass;
use function Filament\Support\generate_href_html;
use function Filament\Support\generate_icon_html;
class TextColumn extends Column implements HasEmbeddedView
{
use CanBeCopied;
use CanWrap;
use Concerns\CanFormatState;
use Concerns\HasColor;
use Concerns\HasDescription;
use Concerns\HasIcon;
use Concerns\HasIconColor;
use HasFontFamily;
use HasLineClamp;
use HasWeight;
protected bool | Closure $isBadge = false;
protected bool | Closure $isBulleted = false;
protected bool | Closure $isListWithLineBreaks = false;
protected int | Closure | null $listLimit = null;
protected TextSize | string | Closure | null $size = null;
protected bool | Closure $isLimitedListExpandable = false;
public function badge(bool | Closure $condition = true): static
{
$this->isBadge = $condition;
return $this;
}
public function bulleted(bool | Closure $condition = true): static
{
$this->isBulleted = $condition;
return $this;
}
public function listWithLineBreaks(bool | Closure $condition = true): static
{
$this->isListWithLineBreaks = $condition;
return $this;
}
public function limitList(int | Closure | null $limit = 3): static
{
$this->listLimit = $limit;
return $this;
}
public function rowIndex(bool $isFromZero = false): static
{
$this->state(static function (HasTable $livewire, stdClass $rowLoop) use ($isFromZero): string {
$rowIndex = $rowLoop->{$isFromZero ? 'index' : 'iteration'};
$recordsPerPage = $livewire->getTableRecordsPerPage();
if (! is_numeric($recordsPerPage)) {
return (string) $rowIndex;
}
return (string) ($rowIndex + ($recordsPerPage * ($livewire->getTablePage() - 1)));
});
return $this;
}
public function size(TextSize | string | Closure | null $size): static
{
$this->size = $size;
return $this;
}
public function getSize(mixed $state): TextSize | string
{
$size = $this->evaluate($this->size, [
'state' => $state,
]);
if (blank($size)) {
return TextSize::Small;
}
if (is_string($size)) {
$size = TextSize::tryFrom($size) ?? $size;
}
if ($size === 'base') {
return TextSize::Medium;
}
return $size;
}
public function isBadge(): bool
{
return (bool) $this->evaluate($this->isBadge);
}
public function isBulleted(): bool
{
return (bool) $this->evaluate($this->isBulleted);
}
public function isListWithLineBreaks(): bool
{
return $this->evaluate($this->isListWithLineBreaks) || $this->isBulleted();
}
public function getListLimit(): ?int
{
return $this->evaluate($this->listLimit);
}
public function expandableLimitedList(bool | Closure $condition = true): static
{
$this->isLimitedListExpandable = $condition;
return $this;
}
public function isLimitedListExpandable(): bool
{
return (bool) $this->evaluate($this->isLimitedListExpandable);
}
public function toEmbeddedHtml(): string
{
$isBadge = $this->isBadge();
$isListWithLineBreaks = $this->isListWithLineBreaks();
$isLimitedListExpandable = $this->isLimitedListExpandable();
$state = $this->getState();
if ($state instanceof Collection) {
$state = $state->all();
}
$attributes = $this->getExtraAttributeBag()
->class([
'fi-ta-text',
'fi-inline' => $this->isInline(),
]);
$alignment = $this->getAlignment();
$attributes = $attributes
->class([
($alignment instanceof Alignment) ? "fi-align-{$alignment->value}" : (is_string($alignment) ? $alignment : ''),
]);
if (blank($state)) {
$attributes = $attributes
->merge([
'x-tooltip' => filled($tooltip = $this->getEmptyTooltip())
? '{
content: ' . Js::from($tooltip) . ',
theme: $store.theme,
allowHTML: ' . Js::from($tooltip instanceof Htmlable) . ',
}'
: null,
], escape: false);
$placeholder = $this->getPlaceholder();
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php if (filled($placeholder)) { ?>
<p class="fi-ta-placeholder">
<?= e($placeholder) ?>
</p>
<?php } ?>
</div>
<?php return ob_get_clean();
}
$shouldOpenUrlInNewTab = $this->shouldOpenUrlInNewTab();
$formatState = function (mixed $stateItem) use ($shouldOpenUrlInNewTab): string {
$url = $this->getUrl($stateItem);
$item = '';
if (filled($url)) {
$item .= '<a ' . generate_href_html($url, $shouldOpenUrlInNewTab)->toHtml() . '>';
}
$item .= e($this->formatState($stateItem));
if (filled($url)) {
$item .= '</a>';
}
return $item;
};
$state = Arr::wrap($state);
$stateCount = count($state);
$listLimit = $this->getListLimit() ?? $stateCount;
$stateOverListLimitCount = 0;
if ($listLimit && ($stateCount > $listLimit)) {
$stateOverListLimitCount = $stateCount - $listLimit;
if (
(! $isListWithLineBreaks) ||
(! $isLimitedListExpandable)
) {
$state = array_slice($state, 0, $listLimit);
}
}
if (($stateCount > 1) && (! $isListWithLineBreaks) && (! $isBadge)) {
$state = [
implode(
', ',
array_map(
fn (mixed $stateItem): string => $formatState($stateItem),
$state,
),
),
];
$stateCount = 1;
$formatState = fn (mixed $stateItem): string => $stateItem;
}
$attributes = $attributes
->class([
'fi-ta-text-has-badges' => $isBadge,
'fi-wrapped' => $this->canWrap(),
]);
$lineClamp = $this->getLineClamp();
$iconPosition = $this->getIconPosition();
$isBulleted = $this->isBulleted();
$getStateItem = function (mixed $stateItem) use ($iconPosition, $isBadge, $lineClamp): array {
$color = $this->getColor($stateItem) ?? ($isBadge ? 'primary' : null);
$iconColor = $this->getIconColor($stateItem);
$size = $this->getSize($stateItem);
$iconHtml = generate_icon_html($this->getIcon($stateItem), attributes: (new ComponentAttributeBag)
->color(IconComponent::class, $iconColor), size: match ($size) {
TextSize::Medium => IconSize::Medium,
TextSize::Large => IconSize::Large,
default => IconSize::Small,
})?->toHtml();
$isCopyable = $this->isCopyable($stateItem);
if ($isCopyable) {
$copyableStateJs = Js::from($this->getCopyableState($stateItem) ?? $this->formatState($stateItem));
$copyMessageJs = Js::from($this->getCopyMessage($stateItem));
$copyMessageDurationJs = Js::from($this->getCopyMessageDuration($stateItem));
}
$tooltip = $this->getTooltip($stateItem);
return [
'attributes' => (new ComponentAttributeBag)
->class([
'fi-ta-text-item',
(($fontFamily = $this->getFontFamily($stateItem)) instanceof FontFamily) ? "fi-font-{$fontFamily->value}" : (is_string($fontFamily) ? $fontFamily : ''),
])
->when(
! $isBadge,
fn (ComponentAttributeBag $attributes) => $attributes
->class([
($size instanceof TextSize) ? "fi-size-{$size->value}" : $size,
(($weight = $this->getWeight($stateItem)) instanceof FontWeight) ? "fi-font-{$weight->value}" : (is_string($weight) ? $weight : ''),
])
->when($lineClamp, fn (ComponentAttributeBag $attributes) => $attributes->style([
"--line-clamp: {$lineClamp}",
]))
->color(ItemComponent::class, $color)
),
'contentAttributes' => ($isBadge || $isCopyable || filled($tooltip))
? (new ComponentAttributeBag)
->merge([
'x-on:click.prevent.stop' => $isCopyable
? <<<JS
window.navigator.clipboard.writeText({$copyableStateJs})
\$tooltip({$copyMessageJs}, {
theme: \$store.theme,
timeout: {$copyMessageDurationJs},
})
JS
: null,
'x-tooltip' => filled($tooltip)
? '{
content: ' . Js::from($tooltip) . ',
theme: $store.theme,
allowHTML: ' . Js::from($tooltip instanceof Htmlable) . ',
}'
: null,
], escape: false)
->class([
'fi-copyable' => $isCopyable,
])
->when(
$isBadge,
fn (ComponentAttributeBag $attributes) => $attributes
->class([
'fi-badge' => $isBadge,
($size instanceof TextSize) ? "fi-size-{$size->value}" : $size,
])
->color(BadgeComponent::class, $color ?? 'primary'),
)
: null,
'iconAfterHtml' => ($iconPosition === IconPosition::After) ? $iconHtml : '',
'iconBeforeHtml' => ($iconPosition === IconPosition::Before) ? $iconHtml : '',
];
};
$descriptionAbove = $this->getDescriptionAbove();
$descriptionBelow = $this->getDescriptionBelow();
$hasDescriptions = filled($descriptionAbove) || filled($descriptionBelow);
if (
($stateCount === 1) &&
(! $isBulleted) &&
(! $hasDescriptions) &&
(! $lineClamp)
) {
$stateItem = Arr::first($state);
[
'attributes' => $stateItemAttributes,
'contentAttributes' => $stateItemContentAttributes,
'iconAfterHtml' => $stateItemIconAfterHtml,
'iconBeforeHtml' => $stateItemIconBeforeHtml,
] = $getStateItem($stateItem);
ob_start(); ?>
<div <?= $attributes
->merge($stateItemAttributes->getAttributes(), escape: false)
->toHtml() ?>>
<?php if ($stateItemContentAttributes) { ?>
<span <?= $stateItemContentAttributes->toHtml() ?>>
<?php } ?>
<?= $stateItemIconBeforeHtml ?>
<?= $formatState($stateItem) ?>
<?= $stateItemIconAfterHtml ?>
<?php if ($stateItemContentAttributes) { ?>
</span>
<?php } ?>
</div>
<?php return ob_get_clean();
}
$attributes = $attributes
->class([
'fi-bulleted' => $isBulleted,
'fi-ta-text-has-line-breaks' => $isListWithLineBreaks,
]);
if ($hasDescriptions || $stateOverListLimitCount) {
$attributes = $attributes
->merge([
'x-data' => ($stateOverListLimitCount && $isLimitedListExpandable)
? '{ isLimited: true }'
: null,
], escape: false)
->class([
'fi-ta-text-has-descriptions' => $hasDescriptions,
'fi-ta-text-list-limited' => $stateOverListLimitCount,
]);
ob_start(); ?>
<div <?= $attributes->toHtml() ?>>
<?php if (filled($descriptionAbove)) { ?>
<p class="fi-ta-text-description">
<?= e($descriptionAbove) ?>
</p>
<?php } ?>
<?php if (($stateCount === 1) && (! $isBulleted)) { ?>
<?php
$stateItem = Arr::first($state);
[
'attributes' => $stateItemAttributes,
'contentAttributes' => $stateItemContentAttributes,
'iconAfterHtml' => $stateItemIconAfterHtml,
'iconBeforeHtml' => $stateItemIconBeforeHtml,
] = $getStateItem($stateItem);
?>
<p <?= $stateItemAttributes->toHtml() ?>>
<?php if ($stateItemContentAttributes) { ?>
<span <?= $stateItemContentAttributes->toHtml() ?>>
<?php } ?>
<?= $stateItemIconBeforeHtml ?>
<?= $formatState($stateItem) ?>
<?= $stateItemIconAfterHtml ?>
<?php if ($stateItemContentAttributes) { ?>
</span>
<?php } ?>
</p>
<?php } else { ?>
<ul>
<?php $stateIteration = 1; ?>
<?php foreach ($state as $stateItem) { ?>
<?php [
'attributes' => $stateItemAttributes,
'contentAttributes' => $stateItemContentAttributes,
'iconAfterHtml' => $stateItemIconAfterHtml,
'iconBeforeHtml' => $stateItemIconBeforeHtml,
] = $getStateItem($stateItem); ?>
<li
<?php if ($stateIteration > $listLimit) { ?>
x-show="! isLimited"
x-cloak
x-transition
<?php } ?>
<?= $stateItemAttributes->toHtml() ?>
>
<?php if ($stateItemContentAttributes) { ?>
<span <?= $stateItemContentAttributes->toHtml() ?>>
<?php } ?>
<?= $stateItemIconBeforeHtml ?>
<?= $formatState($stateItem) ?>
<?= $stateItemIconAfterHtml ?>
<?php if ($stateItemContentAttributes) { ?>
</span>
<?php } ?>
</li>
<?php $stateIteration++ ?>
<?php } ?>
</ul>
<?php } ?>
<?php if ($stateOverListLimitCount) { ?>
<div class="fi-ta-text-list-limited-message">
<?php if ($isLimitedListExpandable) { ?>
<div
role="button"
x-on:click.prevent.stop="isLimited = false"
x-show="isLimited"
class="fi-link fi-size-xs"
>
<?= trans_choice('filament-tables::table.columns.text.actions.expand_list', $stateOverListLimitCount) ?>
</div>
<div
role="button"
x-on:click.prevent.stop="isLimited = true"
x-cloak
x-show="! isLimited"
class="fi-link fi-size-xs"
>
<?= trans_choice('filament-tables::table.columns.text.actions.collapse_list', $stateOverListLimitCount) ?>
</div>
<?php } else { ?>
<?= trans_choice('filament-tables::table.columns.text.more_list_items', $stateOverListLimitCount) ?>
<?php } ?>
</div>
<?php } ?>
<?php if (filled($descriptionBelow)) { ?>
<p class="fi-ta-text-description">
<?= e($descriptionBelow) ?>
</p>
<?php } ?>
</div>
<?php return ob_get_clean();
}
ob_start(); ?>
<ul <?= $attributes->toHtml() ?>>
<?php foreach ($state as $stateItem) { ?>
<?php [
'attributes' => $stateItemAttributes,
'contentAttributes' => $stateItemContentAttributes,
'iconAfterHtml' => $stateItemIconAfterHtml,
'iconBeforeHtml' => $stateItemIconBeforeHtml,
] = $getStateItem($stateItem); ?>
<li <?= $stateItemAttributes->toHtml() ?>>
<?php if ($stateItemContentAttributes) { ?>
<span <?= $stateItemContentAttributes->toHtml() ?>>
<?php } ?>
<?= $stateItemIconBeforeHtml ?>
<?= $formatState($stateItem) ?>
<?= $stateItemIconAfterHtml ?>
<?php if ($stateItemContentAttributes) { ?>
</span>
<?php } ?>
</li>
<?php } ?>
</ul>
<?php return ob_get_clean();
}
}
@@ -0,0 +1,330 @@
<?php
namespace Filament\Tables\Columns;
use BackedEnum;
use Closure;
use Filament\Forms\Components\Concerns\HasExtraInputAttributes;
use Filament\Forms\Components\Concerns\HasInputMode;
use Filament\Forms\Components\Concerns\HasStep;
use Filament\Support\Components\Contracts\HasEmbeddedView;
use Filament\Support\Enums\Alignment;
use Filament\Support\Facades\FilamentAsset;
use Filament\Support\RawJs;
use Filament\Support\View\Components\InputComponent\WrapperComponent\IconComponent;
use Filament\Tables\Columns\Contracts\Editable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Js;
use Illuminate\View\ComponentAttributeBag;
use function Filament\Support\generate_icon_html;
class TextInputColumn extends Column implements Editable, HasEmbeddedView
{
use Concerns\CanBeValidated;
use Concerns\CanUpdateState;
use HasExtraInputAttributes;
use HasInputMode;
use HasStep;
protected string | RawJs | Closure | null $mask = null;
protected string | Closure | null $type = null;
protected string | Htmlable | Closure | null $suffixLabel = null;
protected string | Htmlable | Closure | null $prefixLabel = null;
protected string | BackedEnum | Htmlable | Closure | null $prefixIcon = null;
/**
* @var string | array<string> | Closure | null
*/
protected string | array | Closure | null $prefixIconColor = null;
protected string | BackedEnum | Htmlable | Closure | null $suffixIcon = null;
/**
* @var string | array<string> | Closure | null
*/
protected string | array | Closure | null $suffixIconColor = null;
protected bool | Closure $isPrefixInline = false;
protected bool | Closure $isSuffixInline = false;
protected function setUp(): void
{
parent::setUp();
$this->disabledClick();
}
public function type(string | Closure | null $type): static
{
$this->type = $type;
return $this;
}
public function getType(): string
{
return $this->evaluate($this->type) ?? 'text';
}
public function mask(string | RawJs | Closure | null $mask): static
{
$this->mask = $mask;
return $this;
}
public function getMask(): string | RawJs | null
{
return $this->evaluate($this->mask);
}
public function prefix(string | Htmlable | Closure | null $label, bool | Closure $isInline = false): static
{
$this->prefixLabel = $label;
$this->inlinePrefix($isInline);
return $this;
}
public function suffix(string | Htmlable | Closure | null $label, bool | Closure $isInline = false): static
{
$this->suffixLabel = $label;
$this->inlineSuffix($isInline);
return $this;
}
public function inlinePrefix(bool | Closure $isInline = true): static
{
$this->isPrefixInline = $isInline;
return $this;
}
public function inlineSuffix(bool | Closure $isInline = true): static
{
$this->isSuffixInline = $isInline;
return $this;
}
public function prefixIcon(string | BackedEnum | Htmlable | Closure | null $icon, bool | Closure $isInline = false): static
{
$this->prefixIcon = $icon;
$this->inlinePrefix($isInline);
return $this;
}
/**
* @param string | array<string> | Closure | null $color
*/
public function prefixIconColor(string | array | Closure | null $color = null): static
{
$this->prefixIconColor = $color;
return $this;
}
public function suffixIcon(string | BackedEnum | Htmlable | Closure | null $icon, bool | Closure $isInline = false): static
{
$this->suffixIcon = $icon;
$this->inlineSuffix($isInline);
return $this;
}
/**
* @param string | array<string> | Closure | null $color
*/
public function suffixIconColor(string | array | Closure | null $color = null): static
{
$this->suffixIconColor = $color;
return $this;
}
public function getPrefixLabel(): string | Htmlable | null
{
return $this->evaluate($this->prefixLabel);
}
public function getSuffixLabel(): string | Htmlable | null
{
return $this->evaluate($this->suffixLabel);
}
public function getPrefixIcon(): string | BackedEnum | Htmlable | null
{
return $this->evaluate($this->prefixIcon);
}
public function getSuffixIcon(): string | BackedEnum | Htmlable | null
{
return $this->evaluate($this->suffixIcon);
}
/**
* @return string | array<string> | null
*/
public function getPrefixIconColor(): string | array | null
{
return $this->evaluate($this->prefixIconColor);
}
/**
* @return string | array<string> | null
*/
public function getSuffixIconColor(): string | array | null
{
return $this->evaluate($this->suffixIconColor);
}
public function isPrefixInline(): bool
{
return (bool) $this->evaluate($this->isPrefixInline);
}
public function isSuffixInline(): bool
{
return (bool) $this->evaluate($this->isSuffixInline);
}
public function toEmbeddedHtml(): string
{
$isDisabled = $this->isDisabled();
$state = $this->getState();
$mask = $this->getMask();
$alignment = $this->getAlignment() ?? Alignment::Start;
if (! $alignment instanceof Alignment) {
$alignment = filled($alignment) ? (Alignment::tryFrom($alignment) ?? $alignment) : null;
}
$type = filled($mask) ? 'text' : $this->getType();
$prefixIcon = $this->getPrefixIcon();
$prefixIconColor = $this->getPrefixIconColor();
$prefixLabel = $this->getPrefixLabel();
$suffixIcon = $this->getSuffixIcon();
$suffixIconColor = $this->getSuffixIconColor();
$suffixLabel = $this->getSuffixLabel();
$isPrefixInline = $this->isPrefixInline();
$isSuffixInline = $this->isSuffixInline();
$hasPrefix = $prefixIcon || filled($prefixLabel);
$hasSuffix = $suffixIcon || filled($suffixLabel);
$attributes = $this->getExtraAttributeBag()
->merge([
'x-load' => true,
'x-load-src' => FilamentAsset::getAlpineComponentSrc('columns/text-input', 'filament/tables'),
'x-data' => 'textInputTableColumn({
name: ' . Js::from($this->getName()) . ',
recordKey: ' . Js::from($this->getRecordKey()) . ',
state: ' . Js::from($state) . ',
})',
], escape: false)
->class([
'fi-ta-text-input',
'fi-inline' => $this->isInline(),
]);
$inputAttributes = $this->getExtraInputAttributeBag()
->merge([
'disabled' => $isDisabled,
'wire:loading.attr' => 'disabled',
'wire:target' => implode(',', Table::LOADING_TARGETS),
'x-bind:disabled' => $isDisabled ? null : 'isLoading',
'inputmode' => $this->getInputMode(),
'placeholder' => $this->getPlaceholder(),
'step' => $this->getStep(),
'type' => $type,
'x-mask' . ($mask instanceof RawJs ? ':dynamic' : '') => filled($mask) ? $mask : null,
'x-tooltip' => filled($tooltip = $this->getTooltip($state))
? '{
content: ' . Js::from($tooltip) . ',
theme: $store.theme,
allowHTML: ' . Js::from($tooltip instanceof Htmlable) . ',
}'
: null,
], escape: false)
->class([
'fi-input',
($alignment instanceof Alignment) ? "fi-align-{$alignment->value}" : (is_string($alignment) ? $alignment : ''),
]);
ob_start(); ?>
<div
wire:ignore.self
<?= $attributes->toHtml() ?>
>
<input type="hidden" value="<?= str($state)->replace('"', '\\"') ?>" x-ref="serverState" />
<div
x-bind:class="{
'fi-disabled': isLoading || <?= Js::from($isDisabled) ?>,
'fi-invalid': error !== undefined,
}"
x-tooltip="
error === undefined
? false
: {
content: error,
theme: $store.theme,
}
"
x-on:click.prevent.stop
class="fi-input-wrp"
>
<?php if ($hasPrefix) { ?>
<div
class="fi-input-wrp-prefix fi-input-wrp-prefix-has-content <?= $isPrefixInline ? 'fi-inline' : '' ?> <?= filled($prefixLabel) ? 'fi-input-wrp-prefix-has-label' : '' ?>"
>
<?= generate_icon_html($prefixIcon, null, (new ComponentAttributeBag)
->color(IconComponent::class, $prefixIconColor))?->toHtml() ?>
<?php if (filled($prefixLabel)) { ?>
<span class="fi-input-wrp-label">
<?= e($prefixLabel) ?>
</span>
<?php } ?>
</div>
<?php } ?>
<div class="fi-input-wrp-content-ctn">
<input
x-model.lazy="state"
<?= $inputAttributes->toHtml() ?>
/>
</div>
<?php if ($hasSuffix) { ?>
<div
class="fi-input-wrp-suffix <?= $isSuffixInline ? 'fi-inline' : '' ?> <?= filled($suffixLabel) ? 'fi-input-wrp-suffix-has-label' : '' ?>"
>
<?php if (filled($suffixLabel)) { ?>
<span class="fi-input-wrp-label">
<?= e($suffixLabel) ?>
</span>
<?php } ?>
<?= generate_icon_html($suffixIcon, null, (new ComponentAttributeBag)
->color(IconComponent::class, $suffixIconColor))?->toHtml() ?>
</div>
<?php } ?>
</div>
</div>
<?php return ob_get_clean();
}
}
@@ -0,0 +1,140 @@
<?php
namespace Filament\Tables\Columns;
use Filament\Forms\Components\Concerns\HasToggleColors;
use Filament\Forms\Components\Concerns\HasToggleIcons;
use Filament\Support\Components\Contracts\HasEmbeddedView;
use Filament\Support\Enums\Alignment;
use Filament\Support\Enums\IconSize;
use Filament\Support\Facades\FilamentAsset;
use Filament\Support\View\Components\ToggleComponent;
use Filament\Tables\Columns\Contracts\Editable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Arr;
use Illuminate\Support\Js;
use Illuminate\View\ComponentAttributeBag;
use function Filament\Support\generate_icon_html;
use function Filament\Support\get_component_color_classes;
class ToggleColumn extends Column implements Editable, HasEmbeddedView
{
use Concerns\CanBeValidated;
use Concerns\CanUpdateState;
use HasToggleColors;
use HasToggleIcons;
protected function setUp(): void
{
parent::setUp();
$this->disabledClick();
$this->rules(['boolean']);
}
public function toEmbeddedHtml(): string
{
$offColor = $this->getOffColor() ?? 'gray';
$offIcon = $this->getOffIcon();
$onColor = $this->getOnColor() ?? 'primary';
$onIcon = $this->getOnIcon();
$state = (bool) $this->getState();
$attributes = $this->getExtraAttributeBag()
->merge([
'x-load' => true,
'x-load-src' => FilamentAsset::getAlpineComponentSrc('columns/toggle', 'filament/tables'),
'x-data' => 'toggleTableColumn({
name: ' . Js::from($this->getName()) . ',
recordKey: ' . Js::from($this->getRecordKey()) . ',
state: ' . Js::from($state) . ',
})',
'x-tooltip' => filled($tooltip = $this->getTooltip($state))
? '{
content: ' . Js::from($tooltip) . ',
theme: $store.theme,
allowHTML: ' . Js::from($tooltip instanceof Htmlable) . ',
}'
: null,
], escape: false)
->class([
'fi-ta-toggle',
((($alignment = $this->getAlignment()) instanceof Alignment) ? "fi-align-{$alignment->value}" : (is_string($alignment) ? $alignment : '')),
'fi-inline' => $this->isInline(),
]);
$buttonAttributes = (new ComponentAttributeBag)
->merge([
'disabled' => $this->isDisabled(),
'wire:loading.attr' => 'disabled',
'wire:target' => implode(',', Table::LOADING_TARGETS),
], escape: false)
->class(['fi-toggle']);
ob_start(); ?>
<div
wire:ignore.self
<?= $attributes->toHtml() ?>
>
<input type="hidden" value="<?= $state ? 1 : 0 ?>" x-ref="serverState" />
<div
x-bind:aria-checked="state?.toString()"
x-on:click.prevent.stop="if (! $el.hasAttribute('disabled')) state = ! state"
x-bind:class="state ? '<?= Arr::toCssClasses([
'fi-toggle-on',
...get_component_color_classes(ToggleComponent::class, $onColor),
]) ?>' : '<?= Arr::toCssClasses([
'fi-toggle-off',
...get_component_color_classes(ToggleComponent::class, $offColor),
]) ?>'"
<?php if ($state) { ?> x-cloak <?php } ?>
x-tooltip="
error === undefined
? false
: {
content: error,
theme: $store.theme,
}
"
role="switch"
<?= $buttonAttributes->toHtml() ?>
>
<div>
<div aria-hidden="true">
<?= generate_icon_html($offIcon, size: IconSize::ExtraSmall)?->toHtml() ?>
</div>
<div aria-hidden="true">
<?= generate_icon_html($onIcon, size: IconSize::ExtraSmall)?->toHtml() ?>
</div>
</div>
</div>
<?php if ($state) { ?>
<div
x-cloak="inline-flex"
wire:ignore
class="<?= Arr::toCssClasses([
'fi-toggle fi-toggle-on fi-hidden',
...get_component_color_classes(ToggleComponent::class, $onColor),
]) ?>"
>
<div>
<div aria-hidden="true"></div>
<div aria-hidden="true">
<?= generate_icon_html($onIcon, size: IconSize::ExtraSmall)?->toHtml() ?>
</div>
</div>
</div>
<?php } ?>
</div>
<?php return ob_get_clean();
}
}
@@ -0,0 +1,5 @@
<?php
namespace Filament\Tables\Columns;
class ViewColumn extends Column {}
@@ -0,0 +1,118 @@
<?php
namespace Filament\Tables\Commands\FileGenerators;
use Filament\Support\Commands\FileGenerators\ClassGenerator;
use Filament\Support\Components\Contracts\HasEmbeddedView;
use Filament\Tables\Columns\Column;
use Nette\PhpGenerator\ClassType;
use Nette\PhpGenerator\Method;
use Nette\PhpGenerator\Property;
class ColumnClassGenerator extends ClassGenerator
{
final public function __construct(
protected string $fqn,
protected bool $hasEmbeddedView,
protected ?string $view,
) {}
public function getNamespace(): string
{
return $this->extractNamespace($this->getFqn());
}
/**
* @return array<string>
*/
public function getImports(): array
{
return [
$this->getExtends(),
...($this->hasEmbeddedView() ? [HasEmbeddedView::class] : []),
];
}
public function getBasename(): string
{
return class_basename($this->getFqn());
}
public function getExtends(): string
{
return Column::class;
}
/**
* @return array<class-string>
*/
public function getImplements(): array
{
return [
...($this->hasEmbeddedView() ? [HasEmbeddedView::class] : []),
];
}
protected function addPropertiesToClass(ClassType $class): void
{
$this->addViewPropertyToClass($class);
}
protected function addMethodsToClass(ClassType $class): void
{
$this->addToEmbeddedHtmlMethodToClass($class);
}
protected function addViewPropertyToClass(ClassType $class): void
{
if ($this->hasEmbeddedView()) {
return;
}
$property = $class->addProperty('view', $this->getView())
->setProtected()
->setType('string');
$this->configureViewProperty($property);
}
protected function configureViewProperty(Property $property): void {}
protected function addToEmbeddedHtmlMethodToClass(ClassType $class): void
{
if (! $this->hasEmbeddedView()) {
return;
}
$method = $class->addMethod('toEmbeddedHtml')
->setPublic()
->setReturnType('string')
->setBody(<<<'PHP'
ob_start(); ?>
<div>
<?= e($this->getState()) ?>
</div>
<?php return ob_get_clean();
PHP);
$this->configureToEmbeddedHtmlMethod($method);
}
protected function configureToEmbeddedHtmlMethod(Method $method): void {}
public function getFqn(): string
{
return $this->fqn;
}
public function hasEmbeddedView(): bool
{
return $this->hasEmbeddedView;
}
public function getView(): ?string
{
return $this->view;
}
}
@@ -0,0 +1,239 @@
<?php
namespace Filament\Tables\Commands\FileGenerators\Concerns;
use Filament\Actions\BulkActionGroup;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use Nette\PhpGenerator\Literal;
trait CanGenerateModelTables
{
/**
* @param ?class-string<Model> $model
*/
public function generateTableMethodBody(?string $model = null): string
{
$this->importUnlessPartial(BulkActionGroup::class);
return <<<PHP
return \$table
->query(fn (): {$this->simplifyFqn(Builder::class)} => {$this->simplifyFqn($model)}::query())
->columns([
{$this->outputTableColumns($model)}
])
->filters([
//
])
->headerActions([
//
])
->recordActions([
//
])
->toolbarActions([
{$this->simplifyFqn(BulkActionGroup::class)}::make([
//
]),
]);
PHP;
}
/**
* @param ?class-string<Model> $model
* @param array<string> $exceptColumns
* @return array<string>
*/
public function getTableColumns(?string $model = null, array $exceptColumns = []): array
{
if (! $this->isGenerated()) {
return [];
}
if (blank($model)) {
return [];
}
if (! class_exists($model)) {
return [];
}
$schema = $this->getModelSchema($model);
$table = $this->getModelTable($model);
$columns = [];
foreach ($schema->getColumns($table) as $column) {
if ($column['auto_increment']) {
continue;
}
$type = $this->parseColumnType($column);
if (in_array($type['name'], [
'json',
'text',
])) {
continue;
}
$columnName = $column['name'];
if (in_array($columnName, $exceptColumns)) {
continue;
}
if (str($columnName)->endsWith([
'_token',
])) {
continue;
}
if (str($columnName)->contains([
'password',
])) {
continue;
}
if (str($columnName)->endsWith('_id')) {
$guessedRelationshipName = $this->guessBelongsToRelationshipName($columnName, $model);
if (filled($guessedRelationshipName)) {
$guessedRelationshipTitleColumnName = $this->guessBelongsToRelationshipTitleColumnName($columnName, app($model)->{$guessedRelationshipName}()->getModel()::class);
$columnName = "{$guessedRelationshipName}.{$guessedRelationshipTitleColumnName}";
}
} else {
$guessedRelationshipName = null;
}
$columnData = [];
if (in_array($columnName, [
'id',
'sku',
'uuid',
])) {
$columnData['label'] = [Str::upper($columnName)];
}
if ($columnName === 'email') {
$columnData['label'] = ['Email address'];
}
if ($type['name'] === 'boolean') {
$columnData['type'] = IconColumn::class;
$columnData['boolean'] = [];
} else {
$columnData['type'] = match (true) {
$columnName === 'image', str($columnName)->startsWith('image_'), str($columnName)->contains('_image_'), str($columnName)->endsWith('_image') => ImageColumn::class,
default => TextColumn::class,
};
if (($type['name'] === 'enum') || array_key_exists($columnName, $this->getEnumCasts($model))) {
$columnData['badge'] = [];
}
if ($type['name'] === 'date') {
$columnData['date'] = [];
}
if ($type['name'] === 'time') {
$columnData['time'] = [];
}
if (in_array($type['name'], [
'datetime',
'timestamp',
])) {
$columnData['dateTime'] = [];
}
if (in_array($type['name'], [
'integer',
'decimal',
'float',
'double',
'money',
]) && blank($guessedRelationshipName)) {
$columnData[(in_array($columnName, [
'cost',
'money',
'price',
]) || str($columnName)->endsWith([
'_cost',
'_price',
]) || $type['name'] === 'money') ? 'money' : 'numeric'] = [];
}
if ((in_array($type['name'], [
'string',
'char',
]) && ($columnData['type'] === TextColumn::class)) || filled($guessedRelationshipName)) {
$columnData['searchable'] = [];
}
if (in_array($type['name'], [
'date',
'time',
'datetime',
'timestamp',
'integer',
'decimal',
'float',
'double',
'money',
]) && blank($guessedRelationshipName)) {
$columnData['sortable'] = [];
}
}
if (in_array($columnName, [
'created_at',
'updated_at',
'deleted_at',
])) {
$columnData['toggleable'] = ['isToggledHiddenByDefault' => true];
}
$this->importUnlessPartial($columnData['type']);
$columns[$columnName] = $columnData;
}
return array_map(
function (array $columnData, string $columnName): string {
$column = (string) new Literal("{$this->simplifyFqn($columnData['type'])}::make(?)", [$columnName]);
unset($columnData['type']);
foreach ($columnData as $methodName => $parameters) {
$column .= new Literal(PHP_EOL . " ->{$methodName}(...?:)", [$parameters]);
}
return "{$column},";
},
$columns,
array_keys($columns),
);
}
/**
* @param ?class-string<Model> $model
* @param array<string> $exceptColumns
*/
public function outputTableColumns(?string $model = null, array $exceptColumns = []): string
{
$columns = $this->getTableColumns($model, $exceptColumns);
if (empty($columns)) {
return '//';
}
return implode(PHP_EOL . ' ', $columns);
}
}
@@ -0,0 +1,172 @@
<?php
namespace Filament\Tables\Commands\FileGenerators;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Support\Commands\Concerns\CanReadModelSchemas;
use Filament\Support\Commands\FileGenerators\ClassGenerator;
use Filament\Tables\Commands\FileGenerators\Concerns\CanGenerateModelTables;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Livewire\Component;
use Nette\PhpGenerator\ClassType;
use Nette\PhpGenerator\Literal;
use Nette\PhpGenerator\Method;
use Nette\PhpGenerator\TraitUse;
class LivewireTableComponentClassGenerator extends ClassGenerator
{
use CanGenerateModelTables;
use CanReadModelSchemas;
/**
* @param class-string<Model> $modelFqn
*/
final public function __construct(
protected string $fqn,
protected string $modelFqn,
protected bool $isGenerated,
protected string $view,
) {}
public function getNamespace(): string
{
return $this->extractNamespace($this->getFqn());
}
/**
* @return array<string>
*/
public function getImports(): array
{
return [
$this->getExtends(),
...$this->getImplements(),
InteractsWithActions::class,
InteractsWithTable::class,
InteractsWithSchemas::class,
Table::class,
...($this->hasPartialImports() ? ['Filament\Tables'] : []),
Builder::class,
$this->getModelFqn(),
View::class,
];
}
public function getBasename(): string
{
return class_basename($this->getFqn());
}
public function getExtends(): string
{
return Component::class;
}
/**
* @return array<class-string>
*/
public function getImplements(): array
{
return [
HasActions::class,
HasSchemas::class,
HasTable::class,
];
}
protected function addTraitsToClass(ClassType $class): void
{
$this->addInteractsWithActionsTraitToClass($class);
$this->addInteractsWithTableTraitToClass($class);
$this->addInteractsWithSchemasTraitToClass($class);
}
protected function addMethodsToClass(ClassType $class): void
{
$this->addTableMethodToClass($class);
$this->addRenderMethodToClass($class);
}
protected function addInteractsWithActionsTraitToClass(ClassType $class): void
{
$trait = $class->addTrait(InteractsWithActions::class);
$this->configureInteractsWithActionsTrait($trait);
}
protected function configureInteractsWithActionsTrait(TraitUse $trait): void {}
protected function addInteractsWithTableTraitToClass(ClassType $class): void
{
$trait = $class->addTrait(InteractsWithTable::class);
$this->configureInteractsWithTableTrait($trait);
}
protected function configureInteractsWithTableTrait(TraitUse $trait): void {}
protected function addInteractsWithSchemasTraitToClass(ClassType $class): void
{
$trait = $class->addTrait(InteractsWithSchemas::class);
$this->configureInteractsWithSchemasTrait($trait);
}
protected function configureInteractsWithSchemasTrait(TraitUse $trait): void {}
protected function addTableMethodToClass(ClassType $class): void
{
$method = $class->addMethod('table')
->setPublic()
->setReturnType(Table::class)
->setBody($this->generateTableMethodBody($this->getModelFqn()));
$method->addParameter('table')
->setType(Table::class);
$this->configureTableMethod($method);
}
protected function configureTableMethod(Method $method): void {}
protected function addRenderMethodToClass(ClassType $class): void
{
$method = $class->addMethod('render')
->setPublic()
->setReturnType(View::class)
->setBody(new Literal(<<<'PHP'
return view(?);
PHP, [$this->getView()]));
$this->configureRenderMethod($method);
}
protected function configureRenderMethod(Method $method): void {}
public function getFqn(): string
{
return $this->fqn;
}
/**
* @return class-string<Model>
*/
public function getModelFqn(): string
{
return $this->modelFqn;
}
public function isGenerated(): bool
{
return $this->isGenerated;
}
public function getView(): string
{
return $this->view;
}
}
@@ -0,0 +1,88 @@
<?php
namespace Filament\Tables\Commands\FileGenerators;
use Filament\Support\Commands\Concerns\CanReadModelSchemas;
use Filament\Support\Commands\FileGenerators\ClassGenerator;
use Filament\Tables\Commands\FileGenerators\Concerns\CanGenerateModelTables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Nette\PhpGenerator\ClassType;
use Nette\PhpGenerator\Method;
class TableClassGenerator extends ClassGenerator
{
use CanGenerateModelTables;
use CanReadModelSchemas;
/**
* @param class-string<Model> $modelFqn
*/
final public function __construct(
protected string $fqn,
protected string $modelFqn,
protected bool $isGenerated,
) {}
public function getNamespace(): string
{
return $this->extractNamespace($this->getFqn());
}
/**
* @return array<string>
*/
public function getImports(): array
{
return [
Table::class,
...($this->hasPartialImports() ? ['Filament\Tables'] : []),
Builder::class,
$this->getModelFqn(),
];
}
public function getBasename(): string
{
return class_basename($this->getFqn());
}
protected function addMethodsToClass(ClassType $class): void
{
$this->addConfigureMethodToClass($class);
}
protected function addConfigureMethodToClass(ClassType $class): void
{
$method = $class->addMethod('configure')
->setPublic()
->setStatic()
->setReturnType(Table::class)
->setBody($this->generateTableMethodBody($this->getModelFqn()));
$method->addParameter('table')
->setType(Table::class);
$this->configureConfigureMethod($method);
}
protected function configureConfigureMethod(Method $method): void {}
public function getFqn(): string
{
return $this->fqn;
}
/**
* @return class-string<Model>
*/
public function getModelFqn(): string
{
return $this->modelFqn;
}
public function isGenerated(): bool
{
return $this->isGenerated;
}
}
@@ -0,0 +1,192 @@
<?php
namespace Filament\Tables\Commands;
use Filament\Support\Commands\Concerns\CanAskForComponentLocation;
use Filament\Support\Commands\Concerns\CanAskForViewLocation;
use Filament\Support\Commands\Concerns\CanManipulateFiles;
use Filament\Support\Commands\Exceptions\FailureCommandOutput;
use Filament\Tables\Commands\FileGenerators\ColumnClassGenerator;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\text;
#[AsCommand(name: 'make:filament-table-column', aliases: [
'filament:column',
'filament:table-column',
'make:table-column',
])]
class MakeColumnCommand extends Command
{
use CanAskForComponentLocation;
use CanAskForViewLocation;
use CanManipulateFiles;
protected $description = 'Create a new table column class and cell view';
protected $name = 'make:filament-table-column';
protected string $fqnEnd;
protected string $fqn;
protected string $path;
protected bool $hasEmbeddedView;
protected ?string $view = null;
protected ?string $viewPath = null;
/**
* @var array<string>
*/
protected $aliases = [
'filament:column',
'filament:table-column',
'make:table-column',
];
/**
* @return array<InputArgument>
*/
protected function getArguments(): array
{
return [
new InputArgument(
name: 'name',
mode: InputArgument::OPTIONAL,
description: 'The name of the column to generate, optionally prefixed with directories',
),
];
}
/**
* @return array<InputOption>
*/
protected function getOptions(): array
{
return [
new InputOption(
name: 'embedded-view',
shortcut: 'E',
mode: InputOption::VALUE_NONE,
description: 'Define embedded HTML inside the class instead of using a separate Blade view file',
),
new InputOption(
name: 'force',
shortcut: 'F',
mode: InputOption::VALUE_NONE,
description: 'Overwrite the contents of the files if they already exist',
),
];
}
public function handle(): int
{
try {
$this->configureFqnEnd();
$this->configureHasEmbeddedView();
$this->configureLocation();
$this->createColumn();
$this->createView();
} catch (FailureCommandOutput) {
return static::FAILURE;
}
$this->components->info("Filament table column [{$this->fqn}] created successfully.");
return static::SUCCESS;
}
protected function configureFqnEnd(): void
{
$this->fqnEnd = (string) str($this->argument('name') ?? text(
label: 'What is the column name?',
placeholder: 'StatusSwitcherColumn',
required: true,
))
->trim('/')
->trim('\\')
->trim(' ')
->studly()
->replace('/', '\\');
}
protected function configureHasEmbeddedView(): void
{
$this->hasEmbeddedView = $this->option('embedded-view') || confirm(
label: 'Do you want to embed the HTML of the view in the column class?',
default: false,
hint: 'Defining the HTML of the column in the class instead of in a Blade view file improves the performance of the column, but doesn\'t allow you to use Blade syntax.',
);
}
protected function configureLocation(): void
{
[
$namespace,
$path,
$viewNamespace,
] = $this->askForComponentLocation(
path: 'Tables/Columns',
question: 'Where would you like to create the column?',
);
$this->fqn = "{$namespace}\\{$this->fqnEnd}";
$this->path = (string) str("{$path}\\{$this->fqnEnd}.php")
->replace('\\', '/')
->replace('//', '/');
if ($this->hasEmbeddedView) {
return;
}
[
$this->view,
$this->viewPath,
] = $this->askForViewLocation(
str($this->fqn)
->afterLast('\\Tables\\Columns\\')
->prepend('Filament\\Tables\\Columns\\')
->replace('\\', '/')
->explode('/')
->map(Str::kebab(...))
->implode('.'),
defaultNamespace: $viewNamespace,
);
}
protected function createColumn(): void
{
if (! $this->option('force') && $this->checkForCollision($this->path)) {
throw new FailureCommandOutput;
}
$this->writeFile($this->path, app(ColumnClassGenerator::class, [
'fqn' => $this->fqn,
'hasEmbeddedView' => $this->hasEmbeddedView,
'view' => $this->view,
]));
}
protected function createView(): void
{
if (blank($this->view)) {
return;
}
if (! $this->option('force') && $this->checkForCollision($this->viewPath)) {
throw new FailureCommandOutput;
}
$this->copyStubToApp('ColumnView', $this->viewPath);
}
}
@@ -0,0 +1,250 @@
<?php
namespace Filament\Tables\Commands;
use Filament\Support\Commands\Concerns\CanAskForLivewireComponentLocation;
use Filament\Support\Commands\Concerns\CanAskForViewLocation;
use Filament\Support\Commands\Concerns\CanManipulateFiles;
use Filament\Support\Commands\Exceptions\FailureCommandOutput;
use Filament\Tables\Commands\FileGenerators\LivewireTableComponentClassGenerator;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use function Filament\Support\discover_app_classes;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\suggest;
use function Laravel\Prompts\text;
#[AsCommand(name: 'make:filament-livewire-table', aliases: [
'filament:livewire-table',
'make:livewire-table',
])]
class MakeLivewireTableCommand extends Command
{
use CanAskForLivewireComponentLocation;
use CanAskForViewLocation;
use CanManipulateFiles;
protected $description = 'Create a new Livewire component containing a Filament table';
protected $name = 'make:filament-livewire-table';
/**
* @var array<string>
*/
protected $aliases = [
'filament:livewire-table',
'make:livewire-table',
];
/**
* @var class-string
*/
protected string $fqn;
protected string $fqnEnd;
protected string $path;
/**
* @var class-string<Model>
*/
protected string $modelFqn;
protected string $modelFqnEnd;
protected bool $isGenerated;
protected ?string $view = null;
protected ?string $viewPath = null;
/**
* @return array<InputArgument>
*/
protected function getArguments(): array
{
return [
new InputArgument(
name: 'name',
mode: InputArgument::OPTIONAL,
description: 'The name of the Livewire component to generate, optionally prefixed with directories',
),
new InputArgument(
name: 'model',
mode: InputArgument::OPTIONAL,
description: 'The name of the model to generate the table for, optionally prefixed with directories',
),
];
}
/**
* @return array<InputOption>
*/
protected function getOptions(): array
{
return [
new InputOption(
name: 'generate',
shortcut: 'G',
mode: InputOption::VALUE_NONE,
description: 'Generate the table columns based on the attributes of a model',
),
new InputOption(
name: 'model-namespace',
shortcut: null,
mode: InputOption::VALUE_REQUIRED,
description: 'The namespace of the model class, [' . app()->getNamespace() . 'Models] by default',
),
new InputOption(
name: 'force',
shortcut: 'F',
mode: InputOption::VALUE_NONE,
description: 'Overwrite the contents of the files if they already exist',
),
];
}
public function handle(): int
{
try {
$this->configureFqnEnd();
$this->configureModel();
$this->configureIsGenerated();
$this->configureLocation();
$this->createLivewireComponent();
$this->createView();
} catch (FailureCommandOutput) {
return static::FAILURE;
}
$this->components->info("Livewire component [{$this->fqn}] created successfully.");
return static::SUCCESS;
}
protected function configureFqnEnd(): void
{
$this->fqnEnd = (string) str($this->argument('name') ?? text(
label: 'What is the Livewire component name?',
placeholder: 'BlogPostsTable',
required: true,
))
->trim('/')
->trim('\\')
->trim(' ')
->studly()
->replace('/', '\\');
}
protected function configureModel(): void
{
if ($this->argument('model')) {
$this->modelFqnEnd = (string) str($this->argument('model'))
->trim('/')
->trim('\\')
->trim(' ')
->studly()
->replace('/', '\\');
$modelNamespace = $this->option('model-namespace') ?? app()->getNamespace() . 'Models';
$this->modelFqn = "{$modelNamespace}\\{$this->modelFqnEnd}";
return;
}
$modelFqns = discover_app_classes(parentClass: Model::class);
$this->modelFqn = suggest(
label: 'What is the model?',
options: function (string $search) use ($modelFqns): array {
$search = str($search)->trim()->replace(['\\', '/'], '');
if (blank($search)) {
return $modelFqns;
}
return array_filter(
$modelFqns,
fn (string $class): bool => str($class)->replace(['\\', '/'], '')->contains($search, ignoreCase: true),
);
},
placeholder: app()->getNamespace() . 'Models\\BlogPost',
required: true,
);
$this->modelFqnEnd = class_basename($this->modelFqn);
}
protected function configureIsGenerated(): void
{
$this->isGenerated = $this->option('generate') || confirm(
label: 'Should the table columns be generated from the current database columns?',
default: false,
);
}
protected function configureLocation(): void
{
[
$namespace,
$path,
$viewNamespace,
] = $this->askForLivewireComponentLocation(
question: 'Where would you like to create the table?',
);
$this->fqn = "{$namespace}\\{$this->fqnEnd}";
$this->path = (string) str("{$path}\\{$this->fqnEnd}.php")
->replace('\\', '/')
->replace('//', '/');
[
$this->view,
$this->viewPath,
] = $this->askForViewLocation(
str($this->fqn)
->afterLast('\\Livewire\\')
->prepend('Livewire\\')
->replace('\\', '/')
->explode('/')
->map(Str::kebab(...))
->implode('.'),
defaultNamespace: $viewNamespace,
);
}
protected function createLivewireComponent(): void
{
if (! $this->option('force') && $this->checkForCollision($this->path)) {
throw new FailureCommandOutput;
}
$this->writeFile($this->path, app(LivewireTableComponentClassGenerator::class, [
'fqn' => $this->fqn,
'modelFqn' => $this->modelFqn,
'isGenerated' => $this->isGenerated,
'view' => $this->view,
]));
}
protected function createView(): void
{
if (blank($this->view)) {
return;
}
if (! $this->option('force') && $this->checkForCollision($this->viewPath)) {
throw new FailureCommandOutput;
}
$this->copyStubToApp('LivewireTableView', $this->viewPath);
}
}
@@ -0,0 +1,212 @@
<?php
namespace Filament\Tables\Commands;
use Filament\Support\Commands\Concerns\CanAskForComponentLocation;
use Filament\Support\Commands\Concerns\CanManipulateFiles;
use Filament\Support\Commands\Exceptions\FailureCommandOutput;
use Filament\Tables\Commands\FileGenerators\TableClassGenerator;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use function Filament\Support\discover_app_classes;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\suggest;
use function Laravel\Prompts\text;
#[AsCommand(name: 'make:filament-table', aliases: [
'filament:table',
])]
class MakeTableCommand extends Command
{
use CanAskForComponentLocation;
use CanManipulateFiles;
protected $description = 'Create a new Filament table class';
protected $name = 'make:filament-table';
/**
* @var class-string
*/
protected string $fqn;
protected string $fqnEnd;
protected string $path;
/**
* @var class-string<Model>
*/
protected string $modelFqn;
protected string $modelFqnEnd;
protected bool $isGenerated;
/**
* @var array<string>
*/
protected $aliases = [
'filament:table',
];
/**
* @return array<InputArgument>
*/
protected function getArguments(): array
{
return [
new InputArgument(
name: 'name',
mode: InputArgument::OPTIONAL,
description: 'The name of the table class to generate, optionally prefixed with directories',
),
new InputArgument(
name: 'model',
mode: InputArgument::OPTIONAL,
description: 'The name of the model to generate the table for, optionally prefixed with directories',
),
];
}
/**
* @return array<InputOption>
*/
protected function getOptions(): array
{
return [
new InputOption(
name: 'generate',
shortcut: 'G',
mode: InputOption::VALUE_NONE,
description: 'Generate the table columns based on the attributes of a model',
),
new InputOption(
name: 'model-namespace',
shortcut: null,
mode: InputOption::VALUE_REQUIRED,
description: 'The namespace of the model class, [' . app()->getNamespace() . 'Models] by default',
),
new InputOption(
name: 'force',
shortcut: 'F',
mode: InputOption::VALUE_NONE,
description: 'Overwrite the contents of the files if they already exist',
),
];
}
public function handle(): int
{
try {
$this->configureFqnEnd();
$this->configureModel();
$this->configureIsGenerated();
$this->configureLocation();
$this->createTable();
} catch (FailureCommandOutput) {
return static::FAILURE;
}
$this->components->info("Table [{$this->fqn}] created successfully.");
return static::SUCCESS;
}
protected function configureFqnEnd(): void
{
$this->fqnEnd = (string) str($this->argument('name') ?? text(
label: 'What is the table name?',
placeholder: 'BlogPostsTable',
required: true,
))
->trim('/')
->trim('\\')
->trim(' ')
->studly()
->replace('/', '\\');
}
protected function configureModel(): void
{
if ($this->argument('model')) {
$this->modelFqnEnd = (string) str($this->argument('model'))
->trim('/')
->trim('\\')
->trim(' ')
->studly()
->replace('/', '\\');
$modelNamespace = $this->option('model-namespace') ?? app()->getNamespace() . 'Models';
$this->modelFqn = "{$modelNamespace}\\{$this->modelFqnEnd}";
return;
}
$modelFqns = discover_app_classes(parentClass: Model::class);
$this->modelFqn = suggest(
label: 'What is the model?',
options: function (string $search) use ($modelFqns): array {
$search = str($search)->trim()->replace(['\\', '/'], '');
if (blank($search)) {
return $modelFqns;
}
return array_filter(
$modelFqns,
fn (string $class): bool => str($class)->replace(['\\', '/'], '')->contains($search, ignoreCase: true),
);
},
placeholder: app()->getNamespace() . 'Models\\BlogPost',
required: true,
);
$this->modelFqnEnd = class_basename($this->modelFqn);
}
protected function configureIsGenerated(): void
{
$this->isGenerated = $this->option('generate') || confirm(
label: 'Should the table columns be generated from the current database columns?',
default: false,
);
}
protected function configureLocation(): void
{
[
$namespace,
$path,
] = $this->askForComponentLocation(
path: 'Tables',
question: 'Where would you like to create the table?',
);
$this->fqn = "{$namespace}\\{$this->fqnEnd}";
$this->path = (string) str("{$path}\\{$this->fqnEnd}.php")
->replace('\\', '/')
->replace('//', '/');
}
protected function createTable(): void
{
if (! $this->option('force') && $this->checkForCollision($this->path)) {
throw new FailureCommandOutput;
}
$this->writeFile($this->path, app(TableClassGenerator::class, [
'fqn' => $this->fqn,
'modelFqn' => $this->modelFqn,
'isGenerated' => $this->isGenerated,
]));
}
}
@@ -0,0 +1,17 @@
<?php
namespace Filament\Tables\Concerns;
/**
* @deprecated Override the `table()` method to configure the table.
*/
trait CanBeStriped
{
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function isTableStriped(): bool
{
return false;
}
}
@@ -0,0 +1,30 @@
<?php
namespace Filament\Tables\Concerns;
trait CanDeferLoading
{
public bool $isTableLoaded = false;
/**
* @deprecated Override the `table()` method to configure the table.
*/
public function isTableLoadingDeferred(): bool
{
return false;
}
public function loadTable(): void
{
$this->isTableLoaded = true;
}
public function isTableLoaded(): bool
{
if (! $this->getTable()->isLoadingDeferred()) {
return true;
}
return $this->isTableLoaded;
}
}
@@ -0,0 +1,68 @@
<?php
namespace Filament\Tables\Concerns;
use Filament\Tables\Grouping\Group;
use Illuminate\Database\Eloquent\Builder;
trait CanGroupRecords
{
public ?string $tableGrouping = null;
public function getTableGrouping(): ?Group
{
if ($this->isTableReordering()) {
return null;
}
if (
filled($this->tableGrouping) &&
($group = $this->getTable()->getGroup((string) str($this->tableGrouping)->before(':')))
) {
return $group;
}
if ($this->getTable()->isDefaultGroupSelectable()) {
return null;
}
return $this->getTable()->getDefaultGroup();
}
public function updatedTableGroupColumn(): void
{
$this->resetPage();
}
public function getTableGroupingDirection(): ?string
{
if (blank($this->tableGrouping)) {
return null;
}
if (! str($this->tableGrouping)->contains(':')) {
return 'asc';
}
return match ((string) str($this->tableGrouping)->after(':')) {
'asc' => 'asc',
'desc' => 'desc',
default => null,
};
}
protected function applyGroupingToTableQuery(Builder $query): Builder
{
$group = $this->getTableGrouping();
if (! $group) {
return $query;
}
$group->applyEagerLoading($query);
$group->orderQuery($query, $this->getTableGroupingDirection() ?? 'asc');
return $query;
}
}
@@ -0,0 +1,118 @@
<?php
namespace Filament\Tables\Concerns;
use Filament\Tables\Enums\PaginationMode;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
trait CanPaginateRecords
{
/**
* @var int | string | null
*/
public $tableRecordsPerPage = null;
protected int | string | null $defaultTableRecordsPerPageSelectOption = null;
public function updatedTableRecordsPerPage(): void
{
session()->put([
$this->getTablePerPageSessionKey() => $this->getTableRecordsPerPage(),
]);
$this->resetPage();
}
protected function paginateTableQuery(Builder $query): Paginator | CursorPaginator
{
$perPage = $this->getTableRecordsPerPage();
$mode = $this->getTable()->getPaginationMode();
if ($mode === PaginationMode::Simple) {
return $query->simplePaginate(
perPage: ($perPage === 'all') ? $query->toBase()->getCountForPagination() : $perPage,
pageName: $this->getTablePaginationPageName(),
);
}
if ($mode === PaginationMode::Cursor) {
return $query->cursorPaginate(
perPage: ($perPage === 'all') ? $query->toBase()->getCountForPagination() : $perPage,
cursorName: $this->getTablePaginationPageName(),
);
}
$total = $query->toBase()->getCountForPagination();
/** @var LengthAwarePaginator $records */
$records = $query->paginate(
perPage: ($perPage === 'all') ? $total : $perPage,
pageName: $this->getTablePaginationPageName(),
total: $total,
);
return $records->onEachSide(0);
}
public function getTableRecordsPerPage(): int | string | null
{
return $this->tableRecordsPerPage;
}
public function getTablePage(): int | string
{
return $this->getPage($this->getTablePaginationPageName());
}
public function getDefaultTableRecordsPerPageSelectOption(): int | string
{
$option = session()->get(
$this->getTablePerPageSessionKey(),
$this->defaultTableRecordsPerPageSelectOption ?? $this->getTable()->getDefaultPaginationPageOption(),
);
$pageOptions = $this->getTable()->getPaginationPageOptions();
if (in_array($option, $pageOptions)) {
return $option;
}
session()->remove($this->getTablePerPageSessionKey());
return $pageOptions[0];
}
public function getTablePaginationPageName(): string
{
return $this->getIdentifiedTableQueryStringPropertyNameFor('page');
}
public function getTablePerPageSessionKey(): string
{
$table = md5($this::class);
return "tables.{$table}_per_page";
}
/**
* @deprecated Override the `table()` method to configure the table.
*
* @return array<int | string> | null
*/
protected function getTableRecordsPerPageSelectOptions(): ?array
{
return null;
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function isTablePaginationEnabled(): bool
{
return true;
}
}
@@ -0,0 +1,17 @@
<?php
namespace Filament\Tables\Concerns;
/**
* @deprecated Override the `table()` method to configure the table.
*/
trait CanPollRecords
{
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function getTablePollingInterval(): ?string
{
return null;
}
}
@@ -0,0 +1,82 @@
<?php
namespace Filament\Tables\Concerns;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Query\Expression;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
trait CanReorderRecords
{
public bool $isTableReordering = false;
/**
* @param array<int | string> $order
*/
public function reorderTable(array $order, int | string | null $draggedRecordKey = null): void
{
if (! $this->getTable()->isReorderable()) {
return;
}
$orderColumn = (string) str($this->getTable()->getReorderColumn())->afterLast('.');
DB::transaction(function () use ($order, $orderColumn): void {
if (
(($relationship = $this->getTable()->getRelationship()) instanceof BelongsToMany) &&
in_array($orderColumn, $relationship->getPivotColumns())
) {
foreach ($order as $index => $recordKey) {
$this->getTableRecord($recordKey)->getRelationValue($relationship->getPivotAccessor())->update([
$orderColumn => $index + 1,
]);
}
return;
}
$model = app($this->getTable()->getModel());
$modelKeyName = $model->getKeyName();
$wrappedModelKeyName = $model->getConnection()?->getQueryGrammar()?->wrap($modelKeyName) ?? $modelKeyName;
$model
->newModelQuery()
->whereIn($modelKeyName, array_values($order))
->update([
$orderColumn => new Expression(
'case ' . collect($order)
->when($this->getTable()->getReorderDirection() === 'desc', fn (Collection $order) => $order->reverse()->values())
->map(fn ($recordKey, int $recordIndex): string => 'when ' . $wrappedModelKeyName . ' = ' . DB::getPdo()->quote($recordKey) . ' then ' . ($recordIndex + 1))
->implode(' ') . ' end'
),
]);
});
}
public function toggleTableReordering(): void
{
$this->isTableReordering = ! $this->isTableReordering;
}
public function isTableReordering(): bool
{
return $this->getTable()->isReorderable() && $this->isTableReordering;
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function isTablePaginationEnabledWhileReordering(): bool
{
return false;
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function getTableReorderColumn(): ?string
{
return null;
}
}
@@ -0,0 +1,352 @@
<?php
namespace Filament\Tables\Concerns;
use Filament\Tables\Filters\Indicator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use RecursiveArrayIterator;
use RecursiveIteratorIterator;
trait CanSearchRecords
{
/**
* @var array<string, string | array<string, string | null> | null>
*/
public array $tableColumnSearches = [];
/**
* @var ?string
*/
public $tableSearch = '';
public function updatedTableSearch(): void
{
if ($this->getTable()->persistsSearchInSession()) {
session()->put(
$this->getTableSearchSessionKey(),
$this->tableSearch,
);
}
if ($this->getTable()->shouldDeselectAllRecordsWhenFiltered()) {
$this->deselectAllTableRecords();
}
$this->resetPage();
}
/**
* @param string | null $value
*/
public function updatedTableColumnSearches($value = null, ?string $key = null): void
{
if (blank($value) && filled($key)) {
Arr::forget($this->tableColumnSearches, $key);
}
if ($this->getTable()->persistsColumnSearchesInSession()) {
session()->put(
$this->getTableColumnSearchesSessionKey(),
$this->tableColumnSearches,
);
}
if ($this->getTable()->shouldDeselectAllRecordsWhenFiltered()) {
$this->deselectAllTableRecords();
}
$this->resetPage();
}
protected function applySearchToTableQuery(Builder $query): Builder
{
$this->applyColumnSearchesToTableQuery($query);
$this->applyGlobalSearchToTableQuery($query);
return $query;
}
protected function applyColumnSearchesToTableQuery(Builder $query): Builder
{
$table = $this->getTable();
$shouldSplitSearchTerms = $table->shouldSplitSearchTerms();
foreach ($this->getTableColumnSearches() as $column => $search) {
if (blank($search)) {
continue;
}
$column = $table->getColumn($column);
if (! $column) {
continue;
}
if ($column->isHidden()) {
continue;
}
if (! $column->isIndividuallySearchable()) {
continue;
}
if (! $shouldSplitSearchTerms) {
$isFirst = true;
$column->applySearchConstraint(
$query,
$search,
$isFirst,
);
continue;
}
foreach ($this->extractTableSearchWords($search) as $searchWord) {
$query->where(function (Builder $query) use ($column, $searchWord): void {
$isFirst = true;
$column->applySearchConstraint(
$query,
$searchWord,
$isFirst,
);
});
}
}
return $query;
}
/**
* @return array<string>
*/
protected function extractTableSearchWords(string $search): array
{
return array_filter(
str_getcsv(preg_replace('/\s+/', ' ', $search), separator: ' ', escape: '\\'),
fn ($word): bool => filled($word),
);
}
protected function applyGlobalSearchToTableQuery(Builder $query): Builder
{
$search = $this->getTableSearch();
if (blank($search)) {
return $query;
}
if ($this->getTable()->hasSearchUsingCallback()) {
$this->getTable()->callSearchUsing($query, $search);
return $query;
}
if (! $this->getTable()->shouldSplitSearchTerms()) {
$query->where(function (Builder $query) use ($search): void {
$isFirst = true;
foreach ($this->getTable()->getColumns() as $column) {
if ($column->isHidden()) {
continue;
}
if (! $column->isGloballySearchable()) {
continue;
}
$column->applySearchConstraint(
$query,
$search,
$isFirst,
);
}
$this->getTable()->applyExtraSearchConstraints($query, $search, $isFirst);
});
return $query;
}
foreach ($this->extractTableSearchWords($search) as $searchWord) {
$query->where(function (Builder $query) use ($searchWord): void {
$isFirst = true;
foreach ($this->getTable()->getColumns() as $column) {
if ($column->isHidden()) {
continue;
}
if (! $column->isGloballySearchable()) {
continue;
}
$column->applySearchConstraint(
$query,
$searchWord,
$isFirst,
);
}
$this->getTable()->applyExtraSearchConstraints($query, $searchWord, $isFirst);
});
}
return $query;
}
public function getTableSearch(): ?string
{
return filled($this->tableSearch) ? trim(strval($this->tableSearch)) : null;
}
public function hasTableSearch(): bool
{
return filled($this->tableSearch);
}
public function resetTableSearch(): void
{
$this->tableSearch = '';
$this->updatedTableSearch();
}
public function resetTableColumnSearch(string $column): void
{
$this->updatedTableColumnSearches(null, $column);
}
public function resetTableColumnSearches(): void
{
$this->tableColumnSearches = [];
$this->updatedTableColumnSearches();
}
public function getTableSearchIndicator(): Indicator
{
return Indicator::make(__('filament-tables::table.fields.search.indicator') . ': ' . $this->getTableSearch())
->removeLivewireClickHandler('resetTableSearch');
}
/**
* @return array<Indicator>
*/
public function getTableColumnSearchIndicators(): array
{
$indicators = [];
foreach ($this->getTable()->getColumns() as $column) {
if ($column->isHidden()) {
continue;
}
if (! $column->isIndividuallySearchable()) {
continue;
}
$columnName = $column->getName();
$search = Arr::get($this->tableColumnSearches, $columnName);
if (blank($search)) {
continue;
}
$indicators[] = Indicator::make("{$column->getLabel()}: {$search}")
->removeLivewireClickHandler("resetTableColumnSearch('{$columnName}')");
}
return $indicators;
}
/**
* @param array<string, string | array<string, string | null> | null> $searches
* @return array<string, string | array<string, string | null> | null>
*/
protected function castTableColumnSearches(array $searches): array
{
return array_map(
fn ($search): array | string => is_array($search) ?
$this->castTableColumnSearches($search) :
strval($search),
$searches,
);
}
/**
* @return array<string, string | null>
*/
public function getTableColumnSearches(): array
{
// Example input of `$this->tableColumnSearches`:
// [
// 'number' => '12345 ',
// 'customer' => [
// 'name' => ' john Smith',
// ],
// ]
// The `$this->tableColumnSearches` array is potentially nested.
// So, we iterate through it deeply:
$iterator = new RecursiveIteratorIterator(
new RecursiveArrayIterator($this->tableColumnSearches), /** @phpstan-ignore argument.type */
RecursiveIteratorIterator::SELF_FIRST
);
$searches = [];
$path = [];
foreach ($iterator as $key => $value) {
$path[$iterator->getDepth()] = $key;
if (is_array($value)) {
continue;
}
// Nested array keys are flattened into `dot.syntax`.
$searches[
implode('.', array_slice($path, 0, $iterator->getDepth() + 1))
] = trim(strval($value));
}
return $searches;
// Example output:
// [
// 'number' => '12345',
// 'customer.name' => 'john smith',
// ]
}
public function getTableSearchSessionKey(): string
{
$table = md5($this::class);
return "tables.{$table}_search";
}
public function getTableColumnSearchesSessionKey(): string
{
$table = md5($this::class);
return "tables.{$table}_column_search";
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function shouldPersistTableSearchInSession(): bool
{
return false;
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function shouldPersistTableColumnSearchInSession(): bool
{
return false;
}
}
@@ -0,0 +1,169 @@
<?php
namespace Filament\Tables\Concerns;
use Illuminate\Database\Eloquent\Builder;
trait CanSortRecords
{
public ?string $tableSort = null;
public function sortTable(?string $column = null, ?string $direction = null): void
{
if ($column === $this->getTableSortColumn()) {
$direction ??= match ($this->getTableSortDirection()) {
'asc' => 'desc',
'desc' => null,
default => 'asc',
};
} else {
$direction ??= 'asc';
}
$this->tableSort = $direction ? "{$column}:{$direction}" : null;
$this->updatedTableSort();
}
public function getTableSortColumn(): ?string
{
if (blank($this->tableSort)) {
return null;
}
return (string) str($this->tableSort)->before(':');
}
public function getTableSortDirection(): ?string
{
if (blank($this->tableSort)) {
return null;
}
if (! str($this->tableSort)->contains(':')) {
return 'asc';
}
return match ((string) str($this->tableSort)->after(':')) {
'asc' => 'asc',
'desc' => 'desc',
default => null,
};
}
public function updatedTableSort(): void
{
if ($this->getTable()->persistsSortInSession()) {
session()->put(
$this->getTableSortSessionKey(),
$this->tableSort,
);
}
$this->resetPage();
}
public function updatedTableSortDirection(): void
{
if ($this->getTable()->persistsSortInSession()) {
session()->put(
$this->getTableSortSessionKey(),
$this->tableSort,
);
}
$this->resetPage();
}
protected function applySortingToTableQuery(Builder $query): Builder
{
if ($this->getTable()->isGroupsOnly()) {
return $query;
}
if ($this->isTableReordering()) {
return $query->orderBy($this->getTable()->getReorderColumn(), $this->getTable()->getReorderDirection());
}
if (
$this->getTableSortColumn() &&
$column = $this->getTable()->getSortableVisibleColumn($this->getTableSortColumn())
) {
$sortDirection = $this->getTableSortDirection() === 'desc' ? 'desc' : 'asc';
$column->applySort($query, $sortDirection);
}
$sortDirection = ($this->getTable()->getDefaultSortDirection() ?? $this->getTableSortDirection()) === 'desc' ? 'desc' : 'asc';
$defaultSort = $this->getTable()->getDefaultSort($query, $sortDirection);
if (
is_string($defaultSort) &&
($defaultSort !== $this->getTableSortColumn()) &&
($sortColumn = $this->getTable()->getSortableVisibleColumn($defaultSort))
) {
$sortColumn->applySort($query, $sortDirection);
} elseif (is_string($defaultSort)) {
$query->orderBy($defaultSort, $sortDirection);
}
if ($defaultSort instanceof Builder) {
$query = $defaultSort;
}
if (! $this->getTable()->hasDefaultKeySort()) {
return $query;
}
$qualifiedKeyName = $query->getModel()->getQualifiedKeyName();
foreach ($query->getQuery()->orders ?? [] as $order) {
if (($order['column'] ?? null) === $qualifiedKeyName) {
return $query;
}
if (
is_string($order['column'] ?? null) &&
str($order['column'] ?? null)->contains('.') &&
str($order['column'] ?? null)->afterLast('.')->is(
str($qualifiedKeyName)->afterLast('.')
)
) {
return $query;
}
}
return $query->orderBy($qualifiedKeyName, $sortDirection);
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function getDefaultTableSortColumn(): ?string
{
return null;
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function getDefaultTableSortDirection(): ?string
{
return null;
}
public function getTableSortSessionKey(): string
{
$table = md5($this::class);
return "tables.{$table}_sort";
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function shouldPersistTableSortInSession(): bool
{
return false;
}
}
@@ -0,0 +1,119 @@
<?php
namespace Filament\Tables\Concerns;
use Closure;
use Filament\Support\Services\RelationshipJoiner;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Expression;
use Illuminate\Support\Str;
use stdClass;
trait CanSummarizeRecords
{
public function getAllTableSummaryQuery(): ?Builder
{
return $this->getFilteredTableQuery();
}
public function getPageTableSummaryQuery(): ?Builder
{
return $this->getFilteredSortedTableQuery()?->forPage(
page: $this->getTableRecords()->currentPage(),
perPage: $this->getTableRecords()->perPage(),
);
}
/**
* @return array<string, mixed>
*/
public function getTableSummarySelectedState(?Builder $query = null, ?Closure $modifyQueryUsing = null): array
{
if (! $query) {
return [];
}
$selects = [];
foreach ($this->getTable()->getVisibleColumns() as $column) {
$summarizers = $column->getSummarizers($query);
if (! count($summarizers)) {
continue;
}
if ($column->hasRelationship($query->getModel())) {
continue;
}
$qualifiedAttribute = $query->getModel()->qualifyColumn($column->getName());
foreach ($summarizers as $summarizer) {
if ($summarizer->hasQueryModification()) {
continue;
}
$selectStatements = $summarizer
->query($query)
->getSelectStatements($qualifiedAttribute);
foreach ($selectStatements as $alias => $statement) {
$selects[] = "{$statement} as \"{$alias}\"";
}
}
}
if (! count($selects)) {
return [];
}
$queryToJoin = $query->clone();
$joins = [];
$query = $query->getModel()->resolveConnection($query->getModel()->getConnectionName())
->table($query->toBase(), $query->getModel()->getTable());
if ($modifyQueryUsing) {
$query = $modifyQueryUsing($query) ?? $query;
}
$group = $query->groups[0] ?? null;
$groupSelectAlias = null;
if ($group !== null) {
$groupSelectAlias = Str::random();
if ($group instanceof Expression) {
$group = $group->getValue($query->getGrammar());
}
$selects[] = "{$group} as \"{$groupSelectAlias}\"";
if (filled($groupingRelationshipName = $this->getTableGrouping()?->getRelationshipName())) {
$joins = app(RelationshipJoiner::class)->getLeftJoinsForRelationship(
query: $queryToJoin,
relationship: $groupingRelationshipName,
);
}
}
$query->joins = [
...($query->joins ?? []),
...$joins,
];
return $query
->selectRaw(implode(', ', $selects))
->get()
->mapWithKeys(function (stdClass $state, $key) use ($groupSelectAlias): array {
if ($groupSelectAlias !== null) {
$key = $state->{$groupSelectAlias};
unset($state->{$groupSelectAlias});
}
return [$key => (array) $state];
})
->all();
}
}
@@ -0,0 +1,113 @@
<?php
namespace Filament\Tables\Concerns;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Schemas\Schema;
use Illuminate\Database\Eloquent\Model;
trait HasActions
{
/**
* @deprecated Use the `callMountedAction()` method instead.
*
* @param array<string, mixed> $arguments
*/
public function callMountedTableAction(array $arguments = []): mixed
{
return $this->callMountedAction($arguments);
}
/**
* @deprecated Use `mountAction()` instead.
*
* @param array<string, mixed> $arguments
*/
public function mountTableAction(string $name, ?string $record = null, array $arguments = []): mixed
{
return $this->mountAction($name, $arguments, context: [
'table' => true,
'recordKey' => $record,
]);
}
/**
* @deprecated Use `mountAction()` instead.
*
* @param array<string, mixed> $arguments
*/
public function replaceMountedTableAction(string $name, ?string $record = null, array $arguments = []): void
{
$this->mountAction($name, $arguments, context: [
'table' => true,
'recordKey' => $record,
]);
}
/**
* @deprecated Use `mountedActionShouldOpenModal()` instead.
*/
public function mountedTableActionShouldOpenModal(?Action $mountedAction = null): bool
{
return $this->mountedActionShouldOpenModal($mountedAction);
}
/**
* @deprecated Use `mountedActionHasSchema()` instead.
*/
public function mountedTableActionHasForm(?Action $mountedAction = null): bool
{
return $this->mountedActionHasSchema($mountedAction);
}
/**
* @deprecated Use `getMountedAction()` instead.
*/
public function getMountedTableAction(): ?Action
{
return $this->getMountedAction();
}
/**
* @deprecated Use `getMountedActionSchema()` instead.
*/
public function getMountedTableActionForm(?Action $mountedAction = null): ?Schema
{
return $this->getMountedActionSchema(0, $mountedAction);
}
/**
* @deprecated Use `getMountedAction()?->getRecord()` instead.
*/
public function getMountedTableActionRecord(): ?Model
{
return $this->getMountedAction()?->getRecord();
}
/**
* @deprecated Use `unmountAction()` instead.
*/
public function unmountTableAction(bool $shouldCancelParentActions = true): void
{
$this->unmountAction($shouldCancelParentActions);
}
/**
* @deprecated Override the `table()` method to configure the table.
*
* @return array<Action | ActionGroup>
*/
protected function getTableActions(): array
{
return [];
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function getTableActionsColumnLabel(): ?string
{
return null;
}
}
@@ -0,0 +1,359 @@
<?php
namespace Filament\Tables\Concerns;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Schemas\Schema;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\LazyCollection;
use LogicException;
use function Livewire\invade;
trait HasBulkActions
{
/**
* @var array<int | string>
*/
public array $selectedTableRecords = [];
/**
* @var array<int | string>
*/
public array $deselectedTableRecords = [];
public bool $isTrackingDeselectedTableRecords = false;
protected EloquentCollection | Collection | LazyCollection $cachedSelectedTableRecords;
/**
* @deprecated Use the `callMountedAction()` method instead.
*
* @param array<string, mixed> $arguments
*/
public function callMountedTableBulkAction(array $arguments = []): mixed
{
return $this->callMountedAction($arguments);
}
/**
* @deprecated Use the `mountAction()` method instead.
*
* @param array<int | string> | null $selectedRecords
*/
public function mountTableBulkAction(string $name, ?array $selectedRecords = null): mixed
{
if ($selectedRecords !== null) {
$this->selectedTableRecords = $selectedRecords;
}
return $this->mountAction($name, context: ['table' => true, 'bulk' => true]);
}
/**
* @deprecated Use the `replaceMountedAction()` method instead.
*
* @param array<int | string> | null $selectedRecords
*/
public function replaceMountedTableBulkAction(string $name, ?array $selectedRecords = null): void
{
if ($selectedRecords !== null) {
$this->selectedTableRecords = $selectedRecords;
}
$this->replaceMountedAction($name, context: ['table' => true, 'bulk' => true]);
}
/**
* @deprecated Use the `mountedActionShouldOpenModal()` method instead.
*/
public function mountedTableBulkActionShouldOpenModal(?BulkAction $mountedBulkAction = null): bool
{
return $this->mountedActionShouldOpenModal($mountedBulkAction);
}
/**
* @deprecated Use the `mountedActionHasSchema()` method instead.
*/
public function mountedTableBulkActionHasForm(?BulkAction $mountedBulkAction = null): bool
{
return $this->mountedActionHasSchema($mountedBulkAction);
}
public function deselectAllTableRecords(): void
{
$this->dispatch('deselectAllTableRecords')->self();
}
/**
* @return array<string>
*/
public function getAllSelectableTableRecordKeys(): array
{
$query = $this->getFilteredTableQuery();
if (! $this->getTable()->checksIfRecordIsSelectable()) {
if (! $this->getTable()->hasQuery()) {
/** @phpstan-ignore-next-line */
return $this->getTableRecords()->keys()->all();
}
$records = $this->getTable()->selectsCurrentPageOnly() ?
$this->getTableRecords()->pluck($query->getModel()->getKeyName()) :
$query->toBase()->pluck($query->getModel()->getQualifiedKeyName());
/** @phpstan-ignore-next-line */
return $records->map(fn ($key): string => (string) $key)->all();
}
$records = $this->getTable()->selectsCurrentPageOnly() ?
$this->getTableRecords() :
$query->get();
return $records->reduce(
function (array $carry, Model | array $record, string $key): array {
if (! $this->getTable()->isRecordSelectable($record)) {
return $carry;
}
$carry[] = ($record instanceof Model) ? ((string) $record->getKey()) : $key;
return $carry;
},
initial: [],
);
}
/**
* @return array<string>
*/
public function getGroupedSelectableTableRecordKeys(?string $group): array
{
$query = $this->getFilteredTableQuery();
$tableGrouping = $this->getTableGrouping();
$tableGrouping->scopeQueryByKey($query, $group);
if (! $this->getTable()->checksIfRecordIsSelectable()) {
$records = $this->getTable()->selectsCurrentPageOnly() ?
/** @phpstan-ignore-next-line */
$this->getTableRecords()
->filter(fn (Model $record): bool => $tableGrouping->getStringKey($record) === $group)
->pluck($query->getModel()->getKeyName()) :
$query->toBase()->pluck($query->getModel()->getQualifiedKeyName());
return $records
->map(fn ($key): string => (string) $key)
->all();
}
$records = $this->getTable()->selectsCurrentPageOnly() ?
$this->getTableRecords()->filter(
fn (Model $record) => $tableGrouping->getStringKey($record) === $group,
) :
$query->get();
return $records->reduce(
function (array $carry, Model $record): array {
if (! $this->getTable()->isRecordSelectable($record)) {
return $carry;
}
$carry[] = (string) $record->getKey();
return $carry;
},
initial: [],
);
}
public function getAllSelectableTableRecordsCount(): int
{
if ($this->getTable()->checksIfRecordIsSelectable()) {
/** @var Collection $records */
$records = $this->getTable()->selectsCurrentPageOnly() ?
$this->getTableRecords() :
$this->getFilteredTableQuery()->get();
return $records
->filter(fn (Model | array $record): bool => $this->getTable()->isRecordSelectable($record))
->count();
}
if ($this->getTable()->selectsCurrentPageOnly()) {
return $this->cachedTableRecords->count();
}
if ($this->cachedTableRecords instanceof LengthAwarePaginator) {
return $this->cachedTableRecords->total();
}
return $this->getFilteredTableQuery()?->count() ?? $this->cachedTableRecords->count();
}
public function getSelectedTableRecords(bool $shouldFetchSelectedRecords = true, ?int $chunkSize = null): EloquentCollection | Collection | LazyCollection
{
if (isset($this->cachedSelectedTableRecords)) {
return $this->cachedSelectedTableRecords;
}
$table = $this->getTable();
if (! $table->hasQuery()) {
$resolveSelectedRecords = $table->getResolveSelectedRecordsCallback();
$resolvedSelectedRecords = $resolveSelectedRecords ?
$table->evaluate($resolveSelectedRecords, [
'keys' => $this->selectedTableRecords,
'records' => $this->selectedTableRecords,
'deselectedKeys' => $this->deselectedTableRecords,
'deselectedRecords' => $this->deselectedTableRecords,
'isTrackingDeselectedKeys' => $this->isTrackingDeselectedTableRecords,
'isTrackingDeselectedRecords' => $this->isTrackingDeselectedTableRecords,
]) :
($this->isTrackingDeselectedTableRecords ? $this->getTableRecords()->except($this->deselectedTableRecords) : $this->getTableRecords()->only($this->selectedTableRecords));
$maxSelectableRecords = $table->getMaxSelectableRecords();
if ($maxSelectableRecords && ($resolvedSelectedRecords->count() > $maxSelectableRecords)) {
throw new LogicException("The total count of selected records [{$resolvedSelectedRecords->count()}] must not exceed the maximum selectable records limit [{$maxSelectableRecords}].");
}
return $this->cachedSelectedTableRecords = $resolvedSelectedRecords;
}
$query = $this->getSelectedTableRecordsQuery($shouldFetchSelectedRecords, $chunkSize);
if (! $chunkSize) {
$this->applySortingToTableQuery($query);
}
if (! $shouldFetchSelectedRecords) {
return $this->cachedSelectedTableRecords = $query->toBase()->pluck($query->getModel()->getQualifiedKeyName());
}
if ($chunkSize && $table->getRelationship() instanceof BelongsToMany && ! $table->allowsDuplicates()) {
$invadedRelationship = invade($table->getRelationship());
return $this->cachedSelectedTableRecords = $query->lazyById($chunkSize)
->tapEach(fn (Model $record) => $invadedRelationship->hydratePivotRelation([$record]));
}
if ($chunkSize) {
return $this->cachedSelectedTableRecords = $query->lazyById($chunkSize);
}
return $this->cachedSelectedTableRecords = $this->hydratePivotRelationForTableRecords($query->get());
}
public function getSelectedTableRecordsQuery(bool $shouldFetchSelectedRecords = true, ?int $chunkSize = null): Builder
{
$table = $this->getTable();
$maxSelectableRecords = $table->getMaxSelectableRecords();
if (! ($table->getRelationship() instanceof BelongsToMany && $table->allowsDuplicates())) {
if ($this->isTrackingDeselectedTableRecords) {
$query = $table->getQuery()->whereKeyNot($this->deselectedTableRecords);
} else {
$query = $table->getQuery()->whereKey($this->selectedTableRecords);
}
if ($maxSelectableRecords) {
$query->limit($maxSelectableRecords);
}
if (! $chunkSize) {
$this->applySortingToTableQuery($query);
}
if ($shouldFetchSelectedRecords) {
foreach ($this->getTable()->getColumns() as $column) {
$column->applyEagerLoading($query);
$column->applyRelationshipAggregates($query);
}
}
if ($table->shouldDeselectAllRecordsWhenFiltered()) {
$this->filterTableQuery($query);
}
return $query;
}
/** @var BelongsToMany $relationship */
$relationship = $table->getRelationship();
$pivotClass = $relationship->getPivotClass();
$pivotKeyName = app($pivotClass)->getKeyName();
if ($this->isTrackingDeselectedTableRecords) {
$relationship->wherePivotNotIn($pivotKeyName, $this->deselectedTableRecords);
} else {
$relationship->wherePivotIn($pivotKeyName, $this->selectedTableRecords);
}
if ($maxSelectableRecords) {
$relationship->limit($maxSelectableRecords);
}
if ($shouldFetchSelectedRecords) {
foreach ($this->getTable()->getColumns() as $column) {
$column->applyEagerLoading($relationship);
$column->applyRelationshipAggregates($relationship);
}
}
$relationship = $table->selectPivotDataInQuery($relationship);
return $relationship->getQuery();
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
public function shouldSelectCurrentPageOnly(): bool
{
return false;
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
public function shouldDeselectAllRecordsWhenTableFiltered(): bool
{
return true;
}
/**
* @deprecated Use the `getMountedAction()` method instead.
*/
public function getMountedTableBulkAction(): ?Action
{
return $this->getMountedAction();
}
/**
* @deprecated Use the `getMountedActionSchema()` method instead.
*/
public function getMountedTableBulkActionForm(?BulkAction $mountedBulkAction = null): ?Schema
{
return $this->getMountedActionSchema(0, $mountedBulkAction);
}
/**
* @deprecated Override the `table()` method to configure the table.
*
* @return array<BulkAction>
*/
protected function getTableBulkActions(): array
{
return [];
}
}
@@ -0,0 +1,485 @@
<?php
namespace Filament\Tables\Concerns;
use Filament\Schemas\Schema;
use Filament\Support\Components\Component;
use Filament\Tables\Columns\Column;
use Filament\Tables\Columns\ColumnGroup;
use LogicException;
/**
* @property-read Schema $toggleTableColumnForm
*/
trait HasColumnManager
{
public const TABLE_COLUMN_MANAGER_GROUP_TYPE = 'group';
public const TABLE_COLUMN_MANAGER_COLUMN_TYPE = 'column';
/**
* @var array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool, columns?: array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}>}>
*/
public array $tableColumns = [];
/**
* @var ?array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool,columns?: array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}>}>
*/
protected ?array $cachedDefaultTableColumnState = null;
protected ?bool $hasReorderableTableColumns = null;
public function initTableColumnManager(): void
{
if ($this->getTable()->hasColumnsLayout()) {
return;
}
if (blank($this->tableColumns)) {
$this->tableColumns = $this->loadTableColumnsFromSession();
}
$this->applyTableColumnManager();
}
/**
* @return array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool, columns?: array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}>}>
*/
public function getDefaultTableColumnState(): array
{
return $this->cachedDefaultTableColumnState ??= collect($this->getTable()->getColumnsLayout())
->map(fn (Component $component): ?array => match (true) {
$component instanceof ColumnGroup => $this->mapTableColumnGroupToArray($component),
$component instanceof Column => $this->mapTableColumnToArray($component),
default => null,
})
->filter()
->values()
->all();
}
/**
* @deprecated Use `applyTableColumnManager()` instead.
*/
public function updatedToggledTableColumns(): void
{
$this->applyTableColumnManager();
}
/**
* @param array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool, columns?: array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}>}>|null $state
*/
public function applyTableColumnManager(?array $state = null, bool $wasReordered = false): void
{
if (filled($state)) {
$this->tableColumns = $state;
if ($this->hasReorderableTableColumns()) {
$this->persistHasReorderedTableColumns($wasReordered);
}
}
$this->hasReorderableTableColumns() && session()->get($this->getHasReorderedTableColumnsSessionKey())
? $this->syncReorderableColumnsFromDefaultTableColumnState()
: $this->syncStaticColumnsFromTableColumnState();
$this->persistTableColumns();
}
public function resetTableColumnManager(): void
{
$this->tableColumns = $this->getDefaultTableColumnState();
if ($this->hasReorderableTableColumns()) {
$this->updateTableColumns();
$this->persistHasReorderedTableColumns();
}
$this->persistTableColumns();
}
public function isTableColumnToggledHidden(string $name): bool
{
foreach ($this->tableColumns as $item) {
if ($item['type'] === self::TABLE_COLUMN_MANAGER_COLUMN_TYPE && $item['name'] === $name) {
return ! $item['isToggled'];
}
if ($item['type'] === self::TABLE_COLUMN_MANAGER_GROUP_TYPE && isset($item['columns'])) {
foreach ($item['columns'] as $column) {
if ($column['name'] === $name) {
return ! $column['isToggled'];
}
}
}
}
return true;
}
/**
* @deprecated Use `getTableColumnManagerSessionKey()` instead.
*/
protected function getToggledTableColumnsSessionKey(): string
{
return $this->getTableColumnsSessionKey();
}
public function getTableColumnsSessionKey(): string
{
$table = md5($this::class);
return "tables.{$table}_columns";
}
public function getHasReorderedTableColumnsSessionKey(): string
{
$table = md5($this::class);
return "tables.{$table}_has_reordered_columns";
}
/**
* @return array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool, columns?: array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}>}>
*/
protected function loadTableColumnsFromSession(): array
{
return session()->get(
$this->getTableColumnsSessionKey(),
$this->getDefaultTableColumnState(),
);
}
protected function persistTableColumns(): void
{
session()->put(
$this->getTableColumnsSessionKey(),
$this->tableColumns
);
}
protected function persistHasReorderedTableColumns(bool $wasReordered = false): void
{
session()->put(
$this->getHasReorderedTableColumnsSessionKey(),
$wasReordered || $this->hasReorderedTableColumns()
);
}
/**
* @deprecated Override the `table()` method to configure the table.
*
* @return int | array<string, int | null>
*/
protected function getTableColumnToggleFormColumns(): int | array
{
return 1;
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function getTableColumnToggleFormWidth(): ?string
{
return null;
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function getTableColumnToggleFormMaxHeight(): ?string
{
return null;
}
/**
* @return array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool, columns: array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}>}
*/
protected function mapTableColumnGroupToArray(ColumnGroup $group): array
{
$label = e($group->getLabel());
return [
'type' => self::TABLE_COLUMN_MANAGER_GROUP_TYPE,
'name' => $label,
'label' => $label,
'isHidden' => empty(array_filter($group->getColumns(), fn (Column $column): bool => ! $column->isHidden())),
'isToggled' => true,
'isToggleable' => true,
'isToggledHiddenByDefault' => null,
'columns' => array_values(
array_map(
fn (Column $column): array => $this->mapTableColumnToArray($column),
$group->getColumns()
)
),
];
}
/**
* @return array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}
*/
protected function mapTableColumnToArray(Column $column): array
{
$label = e($column->getLabel());
if (blank($label) && $this->hasReorderableTableColumns()) {
throw new LogicException("The table column [{$column->getName()}] has a blank label. All columns must have labels when they are reorderable.");
}
return [
'type' => self::TABLE_COLUMN_MANAGER_COLUMN_TYPE,
'name' => $column->getName(),
'label' => $label,
'isHidden' => $column->isHidden(),
'isToggled' => ! $column->isToggleable() || ! $column->isToggledHiddenByDefault(),
'isToggleable' => $column->isToggleable(),
'isToggledHiddenByDefault' => $column->isToggleable() ? $column->isToggledHiddenByDefault() : null,
];
}
protected function syncReorderableColumnsFromDefaultTableColumnState(): void
{
$defaultColumnState = $this->getDefaultTableColumnState();
$this->tableColumns = collect($this->tableColumns)
->map(fn (array $item) => $this->syncItemFromDefaultTableColumnState($item, $defaultColumnState))
->filter()
->values()
->merge($this->getNewDefaultColumnStateItems($defaultColumnState))
->all();
$this->updateTableColumns();
}
protected function updateTableColumns(): void
{
$reorderedColumns = collect($this->tableColumns)
->map(function (array $item): Column | ColumnGroup | null {
if ($item['type'] === self::TABLE_COLUMN_MANAGER_COLUMN_TYPE) {
return $this->getTable()->getColumn($item['name']);
}
if ($item['type'] !== self::TABLE_COLUMN_MANAGER_GROUP_TYPE || ! isset($item['columns'])) {
return null;
}
$columns = collect($item['columns'])
->map(fn (array $column): ?Column => $this->getTable()->getColumn($column['name']))
->filter()
->all();
if (empty($columns)) {
return null;
}
return $this->getTable()
->getColumnGroup($item['name'])
->columns($columns);
})
->filter()
->all();
$this->getTable()->columns($reorderedColumns);
}
protected function syncStaticColumnsFromTableColumnState(): void
{
$this->tableColumns = collect($this->getDefaultTableColumnState())
->map(fn (array $item) => $this->syncItemFromTableColumnState($item, $this->tableColumns))
->all();
}
/**
* @param array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool, columns?: array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}>} $item
* @param array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool, columns?: array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}>}> $defaultColumnState
* @return array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool, columns?: array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}>}|null
*/
protected function syncItemFromDefaultTableColumnState(array $item, array $defaultColumnState): ?array
{
$matchingItem = $this->findMatchingTableColumnStateItem($item, $defaultColumnState);
if ($matchingItem === null) {
return null;
}
$syncedItem = $this->syncTableColumnStateItemAttributes($item, $matchingItem);
if ($syncedItem['type'] !== self::TABLE_COLUMN_MANAGER_GROUP_TYPE || ! isset($syncedItem['columns'])) {
return $syncedItem;
}
$syncedItem['columns'] = $this->syncGroupFromDefaultTableColumnState(
$syncedItem['columns'],
$matchingItem['columns'] ?? []
);
if (empty($syncedItem['columns'])) {
return null;
}
return $syncedItem;
}
/**
* @param array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool, columns?: array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}>} $item
* @param array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool, columns?: array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}>}> $tableColumnState
* @return array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool, columns?: array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}>}
*/
protected function syncItemFromTableColumnState(array $item, array $tableColumnState): array
{
$matchingItem = $this->findMatchingTableColumnStateItem($item, $tableColumnState);
if ($matchingItem === null) {
return $item;
}
$syncedItem = $this->syncTableColumnStateItemAttributes($matchingItem, $item);
if ($syncedItem['type'] !== self::TABLE_COLUMN_MANAGER_GROUP_TYPE || ! isset($syncedItem['columns'])) {
return $syncedItem;
}
$syncedItem['columns'] = collect($item['columns'])
->map(fn (array $item) => $this->syncItemFromTableColumnState(
$item,
$matchingItem['columns'] ?? []
))
->all();
return $syncedItem;
}
/**
* @param array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}> $existingColumns
* @param array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}> $defaultColumns
* @return array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}>
*/
protected function syncGroupFromDefaultTableColumnState(array $existingColumns, array $defaultColumns): array
{
$updatedColumns = collect($existingColumns)
->map(function (array $column) use ($defaultColumns): ?array {
$matchingDefault = $this->findMatchingTableColumnStateItem($column, $defaultColumns);
if ($matchingDefault === null) {
return null;
}
return $this->syncTableColumnStateItemAttributes($column, $matchingDefault);
})
->filter()
->values();
$existingNames = $updatedColumns
->pluck('name')
->all();
$newColumnsToAdd = collect($defaultColumns)
->reject(fn (array $column) => in_array($column['name'], $existingNames))
->values();
return $updatedColumns
->merge($newColumnsToAdd)
->all();
}
/**
* @param array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool, columns?: array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}>} $item
* @param array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool, columns?: array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}>} $default
* @return array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool, columns?: array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}>}
*/
protected function syncTableColumnStateItemAttributes(array $item, array $default): array
{
$item['label'] = $default['label'];
$item['isToggleable'] = $default['isToggleable'];
$item['isHidden'] = $default['isHidden'];
if (! $default['isToggleable']) {
$item['isToggled'] = true;
}
if ($item['type'] === self::TABLE_COLUMN_MANAGER_COLUMN_TYPE) {
if (
$default['isToggleable'] &&
is_null($item['isToggledHiddenByDefault'] ?? null) &&
is_bool($default['isToggledHiddenByDefault'])
) {
$item['isToggled'] = ! $default['isToggledHiddenByDefault'];
}
$item['isToggledHiddenByDefault'] = $default['isToggleable'] ? $default['isToggledHiddenByDefault'] : null;
}
return $item;
}
/**
* @param array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool, columns?: array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}>}> $defaultState
* @return array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool, columns?: array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}>}>
*/
protected function getNewDefaultColumnStateItems(array $defaultState): array
{
$existingKeys = collect($this->tableColumns)
->map(fn (array $item) => $item['type'] . ':' . $item['name'])
->all();
return collect($defaultState)
->reject(fn (array $item) => in_array($item['type'] . ':' . $item['name'], $existingKeys))
->values()
->all();
}
/**
* @param array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool, columns?: array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}>} $item
* @param array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool, columns?: array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}>}> $items
* @return array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool, columns?: array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}>}|null
*/
protected function findMatchingTableColumnStateItem(array $item, array $items): ?array
{
return collect($items)
->first(
fn (array $candidate) => $candidate['type'] === $item['type'] &&
$candidate['name'] === $item['name']
);
}
protected function hasReorderableTableColumns(): bool
{
return $this->hasReorderableTableColumns ??= $this->getTable()->hasReorderableColumns();
}
protected function hasReorderedTableColumns(): bool
{
$flattenedDefaultColumnState = $this->flattenTableColumnStateItems($this->getDefaultTableColumnState());
$flattenedColumnState = $this->flattenTableColumnStateItems($this->tableColumns);
$matchingDefaultColumns = collect($flattenedDefaultColumnState)
->filter(fn (string $key) => in_array($key, $flattenedColumnState))
->values()
->all();
return $flattenedColumnState !== $matchingDefaultColumns;
}
/**
* @param array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool, columns?: array<int, array{type: string, name: string, label: string, isHidden: bool, isToggled: bool, isToggleable: bool, isToggledHiddenByDefault: ?bool}>}> $items
* @return array<int, string>
*/
protected function flattenTableColumnStateItems(array $items): array
{
$flattenedItems = [];
foreach ($items as $item) {
$prefix = $item['type'] . ':' . $item['name'];
$flattenedItems[] = $prefix;
if ($item['type'] === self::TABLE_COLUMN_MANAGER_GROUP_TYPE && isset($item['columns'])) {
foreach ($item['columns'] as $column) {
$flattenedItems[] = $prefix . ':' . $column['name'];
}
}
}
return $flattenedItems;
}
}
@@ -0,0 +1,119 @@
<?php
namespace Filament\Tables\Concerns;
use Closure;
use Filament\Support\Components\Attributes\ExposedLivewireMethod;
use Filament\Tables\Columns\Column;
use Filament\Tables\Columns\Contracts\Editable;
use Filament\Tables\Columns\Layout\Component as ColumnLayoutComponent;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Renderless;
use ReflectionMethod;
trait HasColumns
{
public function callTableColumnAction(string $name, string $recordKey): mixed
{
$record = $this->getTableRecord($recordKey);
if (! $record) {
return null;
}
$column = $this->getTable()->getColumn($name);
if (! $column) {
return null;
}
if ($column->isHidden()) {
return null;
}
$action = $column->getAction();
if (! ($action instanceof Closure)) {
return null;
}
return $column->record($record)->evaluate($action);
}
public function updateTableColumnState(string $column, string $record, mixed $input): mixed
{
$column = $this->getTable()->getColumn($column);
if (! ($column instanceof Editable)) {
return null;
}
$record = $this->getTableRecord($record);
if (! $record) {
return null;
}
$column->record($record);
if ($column->isDisabled()) {
return null;
}
try {
$column->validate($input);
} catch (ValidationException $exception) {
return [
'error' => $exception->getMessage(),
];
}
return $column->updateState($input);
}
/**
* @param array<string, mixed> $arguments
*/
public function callTableColumnMethod(string $name, string $recordKey, string $method, array $arguments = []): mixed
{
$column = $this->getTable()->getColumn($name);
if (! $column) {
return null;
}
if (! method_exists($column, $method)) {
return null;
}
$methodReflection = new ReflectionMethod($column, $method);
if (! $methodReflection->getAttributes(ExposedLivewireMethod::class)) {
return null;
}
if ($methodReflection->getAttributes(Renderless::class)) {
$this->skipRender();
}
$record = $this->getTableRecord($recordKey);
if (! $record) {
return null;
}
$column->record($record);
return $column->{$method}(...$arguments);
}
/**
* @deprecated Override the `table()` method to configure the table.
*
* @return array<Column | ColumnLayoutComponent>
*/
protected function getTableColumns(): array
{
return [];
}
}
@@ -0,0 +1,37 @@
<?php
namespace Filament\Tables\Concerns;
use Illuminate\Contracts\View\View;
/**
* @deprecated Override the `table()` method to configure the table.
*/
trait HasContent
{
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function getTableContent(): ?View
{
return null;
}
/**
* @deprecated Override the `table()` method to configure the table.
*
* @return array<string, int | null> | null
*/
protected function getTableContentGrid(): ?array
{
return null;
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function getTableContentFooter(): ?View
{
return null;
}
}
@@ -0,0 +1,55 @@
<?php
namespace Filament\Tables\Concerns;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Illuminate\Contracts\View\View;
/**
* @deprecated Override the `table()` method to configure the table.
*/
trait HasEmptyState
{
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function getTableEmptyState(): ?View
{
return null;
}
/**
* @deprecated Override the `table()` method to configure the table.
*
* @return array<Action | ActionGroup>
*/
protected function getTableEmptyStateActions(): array
{
return [];
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function getTableEmptyStateDescription(): ?string
{
return null;
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function getTableEmptyStateHeading(): ?string
{
return null;
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function getTableEmptyStateIcon(): ?string
{
return null;
}
}
@@ -0,0 +1,259 @@
<?php
namespace Filament\Tables\Concerns;
use Filament\QueryBuilder\Forms\Components\RuleBuilder;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Schema;
use Filament\Tables\Filters\BaseFilter;
use Filament\Tables\Filters\QueryBuilder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
/**
* @property-read Schema $tableFiltersForm
*/
trait HasFilters
{
/**
* @var array<string, mixed> | null
*/
public ?array $tableFilters = null;
/**
* @var array<string, mixed> | null
*/
public ?array $tableDeferredFilters = null;
public function getTableFiltersForm(): Schema
{
if ((! $this->isCachingSchemas) && $this->hasCachedSchema('tableFiltersForm')) {
return $this->getSchema('tableFiltersForm');
}
$table = $this->getTable();
return $this->makeSchema()
->columns($table->getFiltersFormColumns())
->model($table->getModel())
->schema($table->getFiltersFormSchema())
->when(
$table->hasDeferredFilters(),
fn (Schema $schema) => $schema
->statePath('tableDeferredFilters')
->partiallyRender(),
fn (Schema $schema) => $schema
->statePath('tableFilters')
->live(),
);
}
public function updatedTableFilters(): void
{
if ($this->getTable()->hasDeferredFilters()) {
$this->tableDeferredFilters = $this->tableFilters;
}
$this->handleTableFilterUpdates();
}
protected function handleTableFilterUpdates(): void
{
if ($this->getTable()->persistsFiltersInSession()) {
session()->put(
$this->getTableFiltersSessionKey(),
$this->tableFilters,
);
}
if ($this->getTable()->shouldDeselectAllRecordsWhenFiltered()) {
$this->deselectAllTableRecords();
}
$this->resetPage();
}
public function removeTableFilter(string $filterName, ?string $field = null, bool $isRemovingAllFilters = false): void
{
$filter = $this->getTable()->getFilter($filterName);
$filterResetState = $filter->getResetState();
$filterFormGroup = $this->getTableFiltersForm()->getComponentByStatePath($filterName);
if (($filter instanceof QueryBuilder) && blank($field)) {
$filterFormGroup->getChildSchema()->fill();
} elseif ($filter instanceof QueryBuilder) {
$ruleBuilder = $filterFormGroup?->getChildSchema()->getComponent(fn (Component $component): bool => $component instanceof RuleBuilder);
$ruleBuilderRawState = $ruleBuilder?->getRawState() ?? [];
unset($ruleBuilderRawState[$field]);
$ruleBuilder?->rawState($ruleBuilderRawState);
} else {
$filterFields = $filterFormGroup?->getChildSchema()->getFlatFields() ?? [];
if (filled($field) && array_key_exists($field, $filterFields)) {
$filterFields = [$field => $filterFields[$field]];
}
foreach ($filterFields as $fieldName => $field) {
$state = $field->getState();
$field->state($filterResetState[$fieldName] ?? match (true) {
is_array($state) => [],
is_bool($state) => $field->hasNullableBooleanState() ? null : false,
default => null,
});
}
}
if ($isRemovingAllFilters) {
return;
}
if ($this->getTable()->hasDeferredFilters()) {
$this->applyTableFilters();
return;
}
$this->handleTableFilterUpdates();
}
public function removeTableFilters(): void
{
$filters = $this->getTable()->getFilters();
foreach ($filters as $filterName => $filter) {
$this->removeTableFilter(
$filterName,
isRemovingAllFilters: true,
);
}
$this->resetTableSearch();
$this->resetTableColumnSearches();
if ($this->getTable()->hasDeferredFilters()) {
$this->applyTableFilters();
return;
}
$this->handleTableFilterUpdates();
}
public function resetTableFiltersForm(): void
{
$this->getTableFiltersForm()->fill();
if ($this->getTable()->hasDeferredFilters()) {
$this->applyTableFilters();
return;
}
$this->handleTableFilterUpdates();
}
public function applyTableFilters(): void
{
$this->tableFilters = $this->tableDeferredFilters;
$this->handleTableFilterUpdates();
}
protected function applyFiltersToTableQuery(Builder $query): Builder
{
$table = $this->getTable();
if ($table->hasDeferredFilters()) {
$this->getTableFiltersForm()->statePath('tableFilters')->flushCachedAbsoluteStatePaths();
}
try {
foreach ($table->getFilters() as $filter) {
$filter->applyToBaseQuery(
$query,
$this->getTableFilterState($filter->getName()) ?? [],
);
}
return $query->where(function (Builder $query) use ($table): void {
foreach ($table->getFilters() as $filter) {
$filter->apply(
$query,
$this->getTableFilterState($filter->getName()) ?? [],
);
}
});
} finally {
if ($table->hasDeferredFilters()) {
$this->getTableFiltersForm()->statePath('tableDeferredFilters')->flushCachedAbsoluteStatePaths();
}
}
}
public function getTableFilterState(string $name): ?array
{
return Arr::get($this->tableFilters, $this->parseTableFilterName($name));
}
public function getTableFilterFormState(string $name): ?array
{
return Arr::get($this->getTable()->hasDeferredFilters() ? $this->tableDeferredFilters : $this->tableFilters, $this->parseTableFilterName($name));
}
public function parseTableFilterName(string $name): string
{
if (! class_exists($name)) {
return $name;
}
if (! is_subclass_of($name, BaseFilter::class)) {
return $name;
}
return $name::getDefaultName();
}
public function getTableFiltersSessionKey(): string
{
$table = md5($this::class);
return "tables.{$table}_filters";
}
/**
* @deprecated Override the `table()` method to configure the table.
*
* @return array<BaseFilter>
*/
protected function getTableFilters(): array
{
return [];
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function getTableFiltersFormWidth(): ?string
{
return null;
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function getTableFiltersFormMaxHeight(): ?string
{
return null;
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function shouldPersistTableFiltersInSession(): bool
{
return false;
}
}
@@ -0,0 +1,48 @@
<?php
namespace Filament\Tables\Concerns;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Contracts\View\View;
/**
* @deprecated Override the `table()` method to configure the table.
*/
trait HasHeader
{
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function getTableDescription(): string | Htmlable | null
{
return null;
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function getTableHeader(): View | Htmlable | null
{
return null;
}
/**
* @deprecated Override the `table()` method to configure the table.
*
* @return array<Action | ActionGroup>
*/
protected function getTableHeaderActions(): array
{
return [];
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function getTableHeading(): string | Htmlable | null
{
return null;
}
}
@@ -0,0 +1,17 @@
<?php
namespace Filament\Tables\Concerns;
/**
* @deprecated Override the `table()` method to configure the table.
*/
trait HasRecordAction
{
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function getTableRecordAction(): ?string
{
return null;
}
}
@@ -0,0 +1,300 @@
<?php
namespace Filament\Tables\Concerns;
use Filament\Support\ArrayRecord;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use LogicException;
use function Livewire\invade;
trait HasRecords
{
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected bool $allowsDuplicates = false;
protected Collection | Paginator | CursorPaginator | null $cachedTableRecords = null;
public function getFilteredTableQuery(): ?Builder
{
$query = $this->getTable()->getQuery();
if (! $query) {
return null;
}
return $this->filterTableQuery($query);
}
public function filterTableQuery(Builder $query): Builder
{
$this->applyFiltersToTableQuery($query);
$this->applySearchToTableQuery($query);
foreach ($this->getTable()->getVisibleColumns() as $column) {
$column->applyRelationshipAggregates($query);
if ($this->getTable()->isGroupsOnly()) {
continue;
}
$column->applyEagerLoading($query);
}
return $query;
}
public function getFilteredSortedTableQuery(): ?Builder
{
$query = $this->getFilteredTableQuery();
if (! $query) {
return null;
}
$this->applyGroupingToTableQuery($query);
$this->applySortingToTableQuery($query);
return $query;
}
public function getTableQueryForExport(): Builder
{
$query = $this->getTable()->getQuery();
$this->applyFiltersToTableQuery($query);
$this->applySearchToTableQuery($query);
$this->applySortingToTableQuery($query);
return $query;
}
protected function hydratePivotRelationForTableRecords(EloquentCollection | Paginator | CursorPaginator $records): EloquentCollection | Paginator | CursorPaginator
{
$table = $this->getTable();
$relationship = $table->getRelationship();
if ($table->getRelationship() instanceof BelongsToMany && ! $table->allowsDuplicates()) {
invade($relationship)->hydratePivotRelation($records->all());
}
return $records;
}
public function getTableRecords(): Collection | Paginator | CursorPaginator
{
if (! $this->getTable()->hasQuery()) {
if ($this->cachedTableRecords) {
return $this->cachedTableRecords;
}
$records = $this->getTable()->evaluate($this->getTable()->getDataSource(), [
'columnSearches' => fn (): array => $this->getTableColumnSearches(),
'filters' => fn (): ?array => $this->tableFilters,
'page' => fn (): int | string => $this->getTablePage(),
'recordsPerPage' => fn (): int | string => $this->getTableRecordsPerPage(),
'search' => fn (): ?string => $this->getTableSearch(),
'sort' => fn (): array => [$this->getTableSortColumn(), $this->getTableSortDirection()],
'sortColumn' => fn (): ?string => $this->getTableSortColumn(),
'sortDirection' => fn (): ?string => $this->getTableSortDirection(),
]);
if (is_array($records)) {
$collection = collect($records);
} elseif (
($records instanceof Paginator || $records instanceof CursorPaginator) &&
method_exists($records, 'getCollection')
) {
$collection = $records->getCollection();
} else {
$collection = $records;
}
$collection = $collection->mapWithKeys(function (array | Model $record, string | int $key): array {
if ($record instanceof Model) {
return [$record->getKey() => $record];
}
$keyName = ArrayRecord::getKeyName();
$record[$keyName] ??= $key;
$record[$keyName] = (string) $record[$keyName];
return [$record[$keyName] => $record];
});
if (
($records instanceof Paginator || $records instanceof CursorPaginator) &&
method_exists($records, 'setCollection')
) {
$records->setCollection($collection);
} else {
$records = $collection;
}
return $this->cachedTableRecords = $records;
}
if ($translatableContentDriver = $this->makeFilamentTranslatableContentDriver()) {
$setRecordLocales = function (EloquentCollection | Paginator | CursorPaginator $records) use ($translatableContentDriver): EloquentCollection | Paginator | CursorPaginator {
$records->transform(fn (Model $record) => $translatableContentDriver->setRecordLocale($record));
return $records;
};
} else {
$setRecordLocales = fn (EloquentCollection | Paginator | CursorPaginator $records): EloquentCollection | Paginator | CursorPaginator => $records;
}
if ($this->cachedTableRecords) {
return $setRecordLocales($this->cachedTableRecords);
}
$query = $this->getFilteredSortedTableQuery();
if (! $query) {
$livewireClass = $this::class;
throw new LogicException("Table [{$livewireClass}] must have a [query()], [relationship()], or [records()].");
}
if (
(! $this->getTable()->isPaginated()) ||
($this->isTableReordering() && (! $this->getTable()->isPaginatedWhileReordering()))
) {
return $setRecordLocales($this->cachedTableRecords = $this->hydratePivotRelationForTableRecords($query->get()));
}
return $setRecordLocales($this->cachedTableRecords = $this->hydratePivotRelationForTableRecords($this->paginateTableQuery($query)));
}
/**
* @return Model | array<string, mixed> | null
*/
protected function resolveTableRecord(?string $key): Model | array | null
{
if ($key === null) {
return null;
}
if (! $this->getTable()->hasQuery()) {
return $this->getTable()->getRecords()[$key] ?? null;
}
if (! ($this->getTable()->getRelationship() instanceof BelongsToMany)) {
return $this->getFilteredTableQuery()->find($key);
}
/** @var BelongsToMany $relationship */
$relationship = $this->getTable()->getRelationship();
$pivotClass = $relationship->getPivotClass();
$pivotKeyName = app($pivotClass)->getKeyName();
$table = $this->getTable();
$this->applyFiltersToTableQuery($relationship->getQuery());
$query = $table->allowsDuplicates() ?
$relationship->wherePivot($pivotKeyName, $key) :
$relationship->where($relationship->getQualifiedRelatedKeyName(), $key);
$record = $table->selectPivotDataInQuery($query)->first();
return $record?->setRawAttributes($record->getRawOriginal());
}
/**
* @return Model | array<string, mixed> | null
*/
public function getTableRecord(?string $key): Model | array | null
{
$record = $this->resolveTableRecord($key);
if ($record && filled($this->getActiveTableLocale())) {
$this->makeFilamentTranslatableContentDriver()->setRecordLocale($record);
}
return $record;
}
/**
* @param Model | array<string, mixed> $record
*/
public function getTableRecordKey(Model | array $record): string
{
if (is_array($record)) {
return $record[ArrayRecord::getKeyName()] ?? throw new LogicException('Record arrays must have a unique [key] entry for identification.');
}
$table = $this->getTable();
if (! ($table->getRelationship() instanceof BelongsToMany && $table->allowsDuplicates())) {
return $record->getKey();
}
/** @var BelongsToMany $relationship */
$relationship = $table->getRelationship();
$pivotClass = $relationship->getPivotClass();
$pivotKeyName = app($pivotClass)->getKeyName();
return $record->getAttributeValue($pivotKeyName);
}
public function getAllTableRecordsCount(): int
{
if ($this->cachedTableRecords instanceof LengthAwarePaginator) {
return $this->cachedTableRecords->total();
}
return $this->getFilteredTableQuery()->count();
}
public function flushCachedTableRecords(): void
{
$this->cachedTableRecords = null;
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
public function allowsDuplicates(): bool
{
return $this->allowsDuplicates;
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
public function getTableRecordTitle(Model $record): ?string
{
return null;
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
public function getTableModelLabel(): ?string
{
return null;
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
public function getTablePluralModelLabel(): ?string
{
return null;
}
}
@@ -0,0 +1,294 @@
<?php
namespace Filament\Tables\Concerns;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Livewire\WithPagination;
trait InteractsWithTable
{
use CanBeStriped;
use CanDeferLoading;
use CanGroupRecords;
use CanPaginateRecords;
use CanPollRecords;
use CanReorderRecords;
use CanSearchRecords;
use CanSortRecords;
use CanSummarizeRecords;
use HasActions;
use HasBulkActions;
use HasColumnManager;
use HasColumns;
use HasContent;
use HasEmptyState;
use HasFilters;
use HasHeader;
use HasRecordAction;
use HasRecords;
use WithPagination {
WithPagination::resetPage as resetLivewirePage;
WithPagination::setPage as setLivewirePage;
}
protected Table $table;
protected bool $hasTableModalRendered = false;
protected bool $shouldMountInteractsWithTable = false;
public function bootedInteractsWithTable(): void
{
$this->table = $this->table($this->makeTable());
$this->cacheSchema('tableFiltersForm', $this->getTableFiltersForm());
$this->cacheMountedActions($this->mountedActions);
$this->initTableColumnManager();
if (! $this->shouldMountInteractsWithTable) {
return;
}
$shouldPersistFiltersInSession = $this->getTable()->persistsFiltersInSession();
$filtersSessionKey = $this->getTableFiltersSessionKey();
if (! count($this->tableFilters ?? [])) {
$this->tableFilters = null;
}
if (
($this->tableFilters === null) &&
$shouldPersistFiltersInSession &&
session()->has($filtersSessionKey)
) {
$this->tableFilters = session()->get($filtersSessionKey) ?? [];
}
// https://github.com/filamentphp/filament/pull/7999
if ($this->tableFilters) {
$this->normalizeTableFilterValuesFromQueryString($this->tableFilters);
}
$this->getTableFiltersForm()->fill($this->tableFilters);
if ($this->getTable()->hasDeferredFilters()) {
$this->tableFilters = $this->tableDeferredFilters;
}
if ($shouldPersistFiltersInSession) {
session()->put(
$filtersSessionKey,
$this->tableFilters,
);
}
if ($this->getTable()->isDefaultGroupSelectable()) {
$this->tableGrouping = "{$this->getTable()->getDefaultGroup()->getId()}:asc";
}
$shouldPersistSearchInSession = $this->getTable()->persistsSearchInSession();
$searchSessionKey = $this->getTableSearchSessionKey();
if (
blank($this->tableSearch) &&
$shouldPersistSearchInSession &&
session()->has($searchSessionKey)
) {
$this->tableSearch = session()->get($searchSessionKey);
}
$this->tableSearch = strval($this->tableSearch);
if ($shouldPersistSearchInSession) {
session()->put(
$searchSessionKey,
$this->tableSearch,
);
}
$shouldPersistColumnSearchesInSession = $this->getTable()->persistsColumnSearchesInSession();
$columnSearchesSessionKey = $this->getTableColumnSearchesSessionKey();
if (
(blank($this->tableColumnSearches) || ($this->tableColumnSearches === [])) &&
$shouldPersistColumnSearchesInSession &&
session()->has($columnSearchesSessionKey)
) {
$this->tableColumnSearches = session()->get($columnSearchesSessionKey) ?? [];
}
$this->tableColumnSearches = $this->castTableColumnSearches(
$this->tableColumnSearches ?? [],
);
if ($shouldPersistColumnSearchesInSession) {
session()->put(
$columnSearchesSessionKey,
$this->tableColumnSearches,
);
}
$shouldPersistSortInSession = $this->getTable()->persistsSortInSession();
$sortSessionKey = $this->getTableSortSessionKey();
if (
blank($this->tableSort) &&
$shouldPersistSortInSession &&
session()->has($sortSessionKey)
) {
$this->tableSort = session()->get($sortSessionKey);
}
if ($shouldPersistSortInSession) {
session()->put(
$sortSessionKey,
$this->tableSort,
);
}
if ($this->getTable()->isPaginated()) {
$this->tableRecordsPerPage = $this->getDefaultTableRecordsPerPageSelectOption();
}
}
public function mountInteractsWithTable(): void
{
$this->shouldMountInteractsWithTable = true;
}
public function table(Table $table): Table
{
return $table;
}
public function getTable(): Table
{
return $this->table;
}
protected function makeTable(): Table
{
return Table::make($this)
->query(fn (): Builder | Relation | null => $this->getTableQuery())
->when($this->getTableActions(), fn (Table $table, array $actions): Table => $table->actions($actions))
->when($this->getTableActionsColumnLabel(), fn (Table $table, string $actionsColumnLabel): Table => $table->actionsColumnLabel($actionsColumnLabel))
->when($this->getTableColumns(), fn (Table $table, array $columns): Table => $table->columns($columns))
->when(($columnManagerColumns = $this->getTableColumnToggleFormColumns()) !== 1, fn (Table $table): Table => $table->columnManagerColumns($columnManagerColumns))
->when($this->getTableColumnToggleFormMaxHeight(), fn (Table $table, string $columnManagerMaxHeight): Table => $table->columnManagerMaxHeight($columnManagerMaxHeight))
->when($this->getTableColumnToggleFormWidth(), fn (Table $table, string $columnManagerWidth): Table => $table->columnManagerWidth($columnManagerWidth))
->when($this->getTableContent(), fn (Table $table, View $content): Table => $table->content($content))
->when($this->getTableContentFooter(), fn (Table $table, View $contentFooter): Table => $table->contentFooter($contentFooter))
->when($this->getTableContentGrid(), fn (Table $table, array $contentGrid): Table => $table->contentGrid($contentGrid))
->when($this->getDefaultTableSortColumn(), fn (Table $table, string $defaultSortColumn): Table => $table->defaultSort($defaultSortColumn, $this->getDefaultTableSortDirection()))
->when($this->isTableLoadingDeferred(), fn (Table $table): Table => $table->deferLoading())
->when($this->getTableDescription(), fn (Table $table, string | Htmlable $description): Table => $table->description($description))
->when(! $this->shouldDeselectAllRecordsWhenTableFiltered(), fn (Table $table): Table => $table->deselectAllRecordsWhenFiltered(false))
->when($this->getTableEmptyState(), fn (Table $table, View $emptyState): Table => $table->emptyState($emptyState))
->when($this->getTableEmptyStateActions(), fn (Table $table, array $emptyStateActions): Table => $table->emptyStateActions($emptyStateActions))
->when($this->getTableEmptyStateDescription(), fn (Table $table, string $emptyStateDescription): Table => $table->emptyStateDescription($emptyStateDescription))
->when($this->getTableEmptyStateHeading(), fn (Table $table, string $emptyStateHeading): Table => $table->emptyStateHeading($emptyStateHeading))
->when($this->getTableEmptyStateIcon(), fn (Table $table, string $emptyStateIcon): Table => $table->emptyStateIcon($emptyStateIcon))
->when($this->getTableFilters(), fn (Table $table, array $filters): Table => $table->filters($filters))
->when($this->getTableFiltersFormMaxHeight(), fn (Table $table, string $filtersFormMaxHeight): Table => $table->filtersFormMaxHeight($filtersFormMaxHeight))
->when($this->getTableFiltersFormWidth(), fn (Table $table, string $filtersFormWidth): Table => $table->filtersFormWidth($filtersFormWidth))
->when($this->getTableBulkActions(), fn (Table $table, array $groupedBulkActions): Table => $table->groupedBulkActions($groupedBulkActions))
->when($this->getTableHeader(), fn (Table $table, View | Htmlable $header): Table => $table->header($header))
->when($this->getTableHeaderActions(), fn (Table $table, array $headerActions): Table => $table->headerActions($headerActions))
->when($this->getTableModelLabel(), fn (Table $table, string $modelLabel): Table => $table->modelLabel($modelLabel))
->when(! $this->isTablePaginationEnabled(), fn (Table $table): Table => $table->paginated(false))
->when($this->isTablePaginationEnabledWhileReordering(), fn (Table $table): Table => $table->paginatedWhileReordering())
->when($this->getTableRecordsPerPageSelectOptions(), fn (Table $table, array $paginationPageOptions): Table => $table->paginationPageOptions($paginationPageOptions))
->when($this->shouldPersistTableFiltersInSession(), fn (Table $table): Table => $table->persistFiltersInSession())
->when($this->shouldPersistTableSearchInSession(), fn (Table $table): Table => $table->persistSearchInSession())
->when($this->shouldPersistTableColumnSearchInSession(), fn (Table $table): Table => $table->persistColumnSearchesInSession())
->when($this->shouldPersistTableSortInSession(), fn (Table $table): Table => $table->persistSortInSession())
->when($this->getTablePluralModelLabel(), fn (Table $table, string $pluralModelLabel): Table => $table->pluralModelLabel($pluralModelLabel))
->when($this->getTablePollingInterval(), fn (Table $table, string $pollingInterval): Table => $table->poll($pollingInterval))
->when($this->getTableRecordAction(), fn (Table $table, string $recordAction): Table => $table->recordAction($recordAction))
->recordTitle(fn (Model $record): ?string => $this->getTableRecordTitle($record))
->when($this->getTableReorderColumn(), fn (Table $table, string $reorderColumn): Table => $table->reorderable($reorderColumn))
->when($this->shouldSelectCurrentPageOnly(), fn (Table $table): Table => $table->selectCurrentPageOnly())
->when($this->isTableStriped(), fn (Table $table): Table => $table->striped());
}
protected function getTableQueryStringIdentifier(): ?string
{
return null;
}
public function getIdentifiedTableQueryStringPropertyNameFor(string $property): string
{
if (filled($identifier = $this->getTable()->getQueryStringIdentifier())) {
return $identifier . ucfirst($property);
}
return $property;
}
public function getActiveTableLocale(): ?string
{
return null;
}
public function resetPage(?string $pageName = null): void
{
$this->resetLivewirePage($pageName ?? $this->getTablePaginationPageName());
}
public function setPage(int | string $page, ?string $pageName = null): void
{
$defaultPageName = $this->getTablePaginationPageName();
$pageName ??= $defaultPageName;
$this->setLivewirePage($page, $pageName);
if (($pageName === $defaultPageName) && $this->getTable()->shouldScrollToTopOnPageChange()) {
$this->dispatch('scrollToTopOfTable')->self();
}
}
/**
* @deprecated Override the `table()` method to configure the table.
*/
protected function getTableQuery(): Builder | Relation | null
{
return null;
}
/**
* @param array<string, mixed> $data
*/
protected function normalizeTableFilterValuesFromQueryString(array &$data): void
{
foreach ($data as &$value) {
if (is_array($value)) {
$this->normalizeTableFilterValuesFromQueryString($value);
} elseif ($value === 'null') {
$value = null;
} elseif ($value === 'false') {
$value = false;
} elseif ($value === 'true') {
$value = true;
}
}
}
public function resetTable(): void
{
$this->bootedInteractsWithTable();
$this->resetTableFiltersForm();
$this->resetPage();
$this->flushCachedTableRecords();
}
}
@@ -0,0 +1,145 @@
<?php
namespace Filament\Tables\Contracts;
use Filament\Actions\Action;
use Filament\Schemas\Schema;
use Filament\Support\Contracts\TranslatableContentDriver;
use Filament\Tables\Filters\Indicator;
use Filament\Tables\Grouping\Group;
use Filament\Tables\Table;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\LazyCollection;
interface HasTable
{
public function callTableColumnAction(string $name, string $recordKey): mixed;
public function deselectAllTableRecords(): void;
public function getActiveTableLocale(): ?string;
/**
* @return array<int | string>
*/
public function getAllSelectableTableRecordKeys(): array;
public function getAllTableRecordsCount(): int;
public function getAllSelectableTableRecordsCount(): int;
/**
* @return array<string, mixed> | null
*/
public function getTableFilterState(string $name): ?array;
/**
* @return array<string, mixed> | null
*/
public function getTableFilterFormState(string $name): ?array;
public function getSelectedTableRecords(bool $shouldFetchSelectedRecords = true, ?int $chunkSize = null): EloquentCollection | Collection | LazyCollection;
public function getSelectedTableRecordsQuery(bool $shouldFetchSelectedRecords = true, ?int $chunkSize = null): Builder;
public function parseTableFilterName(string $name): string;
public function getTableGrouping(): ?Group;
public function getMountedTableAction(): ?Action;
public function getMountedTableActionForm(): ?Schema;
public function getMountedTableActionRecord(): ?Model;
public function getMountedTableBulkAction(): ?Action;
public function getMountedTableBulkActionForm(): ?Schema;
public function getTable(): Table;
public function getTableFiltersForm(): Schema;
public function getTableRecords(): Collection | Paginator | CursorPaginator;
public function getTableRecordsPerPage(): int | string | null;
public function getTablePage(): int | string;
public function getTableSortColumn(): ?string;
public function getTableSortDirection(): ?string;
public function getAllTableSummaryQuery(): ?Builder;
public function getPageTableSummaryQuery(): ?Builder;
public function isTableColumnToggledHidden(string $name): bool;
/**
* @return Model | array<string, mixed> | null
*/
public function getTableRecord(?string $key): Model | array | null;
/**
* @param Model | array<string, mixed> $record
*/
public function getTableRecordKey(Model | array $record): string;
public function toggleTableReordering(): void;
public function isTableReordering(): bool;
public function isTableLoaded(): bool;
public function hasTableSearch(): bool;
public function resetTableSearch(): void;
public function resetTableColumnSearch(string $column): void;
public function getTableSearchIndicator(): Indicator;
/**
* @return array<Indicator>
*/
public function getTableColumnSearchIndicators(): array;
public function getFilteredTableQuery(): ?Builder;
public function getFilteredSortedTableQuery(): ?Builder;
public function getTableQueryForExport(): Builder;
public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver;
/**
* @param array<string, mixed> $arguments
*/
public function callMountedTableAction(array $arguments = []): mixed;
/**
* @param array<string, mixed> $arguments
*/
public function mountTableAction(string $name, ?string $record = null, array $arguments = []): mixed;
/**
* @param array<string, mixed> $arguments
*/
public function replaceMountedTableAction(string $name, ?string $record = null, array $arguments = []): void;
/**
* @param array<int | string> | null $selectedRecords
*/
public function mountTableBulkAction(string $name, ?array $selectedRecords = null): mixed;
/**
* @param array<int | string> | null $selectedRecords
*/
public function replaceMountedTableBulkAction(string $name, ?array $selectedRecords = null): void;
}
@@ -0,0 +1,26 @@
<?php
namespace Filament\Tables\Enums;
enum FiltersLayout
{
case AboveContent;
case AboveContentCollapsible;
case BelowContent;
case BeforeContent;
case AfterContent;
case BeforeContentCollapsible;
case AfterContentCollapsible;
case Dropdown;
case Modal;
case Hidden;
}
@@ -0,0 +1,12 @@
<?php
namespace Filament\Tables\Enums;
enum PaginationMode
{
case Default;
case Simple;
case Cursor;
}
@@ -0,0 +1,16 @@
<?php
namespace Filament\Tables\Enums;
enum RecordActionsPosition
{
case AfterCells;
case AfterColumns;
case AfterContent;
case BeforeCells;
case BeforeColumns;
}
@@ -0,0 +1,10 @@
<?php
namespace Filament\Tables\Enums;
enum RecordCheckboxPosition
{
case BeforeCells;
case AfterCells;
}
@@ -0,0 +1,66 @@
<?php
namespace Filament\Tables\Filters;
use Filament\Support\Components\Component;
use LogicException;
class BaseFilter extends Component
{
use Concerns\BelongsToTable;
use Concerns\CanBeHidden;
use Concerns\CanResetState;
use Concerns\CanSpanColumns;
use Concerns\HasColumns;
use Concerns\HasDefaultState;
use Concerns\HasIndicators;
use Concerns\HasLabel;
use Concerns\HasName;
use Concerns\HasSchema;
use Concerns\InteractsWithTableQuery;
protected string $evaluationIdentifier = 'filter';
final public function __construct(string $name)
{
$this->name($name);
}
public static function make(?string $name = null): static
{
$filterClass = static::class;
$name ??= static::getDefaultName();
if (blank($name)) {
throw new LogicException("Filter of class [$filterClass] must have a unique name, passed to the [make()] method.");
}
$static = app($filterClass, ['name' => $name]);
$static->configure();
return $static;
}
public static function getDefaultName(): ?string
{
return null;
}
public function getActiveCount(): int
{
return count($this->getIndicators());
}
/**
* @return array<mixed>
*/
protected function resolveDefaultClosureDependencyForEvaluationByName(string $parameterName): array
{
return match ($parameterName) {
'livewire' => [$this->getLivewire()],
'table' => [$this->getTable()],
default => parent::resolveDefaultClosureDependencyForEvaluationByName($parameterName),
};
}
}
@@ -0,0 +1,44 @@
<?php
namespace Filament\Tables\Filters\Concerns;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
trait BelongsToTable
{
protected Table $table;
public function table(Table $table): static
{
$this->table = $table;
return $this;
}
public function getTable(): Table
{
return $this->table;
}
public function getLivewire(): HasTable
{
return $this->getTable()->getLivewire();
}
/**
* @return array<string, mixed>
*/
public function getState(): array
{
return $this->getLivewire()->getTableFilterState($this->getName()) ?? [];
}
/**
* @return array<string, mixed>
*/
public function getFormState(): array
{
return $this->getLivewire()->getTableFilterFormState($this->getName()) ?? [];
}
}
@@ -0,0 +1,78 @@
<?php
namespace Filament\Tables\Filters\Concerns;
use Closure;
use Filament\Tables\Contracts\HasTable;
use Illuminate\Support\Arr;
trait CanBeHidden
{
protected bool | Closure $isHidden = false;
protected bool | Closure $isVisible = true;
public function hidden(bool | Closure $condition = true): static
{
$this->isHidden = $condition;
return $this;
}
/**
* @param string | array<string> $livewireComponents
*/
public function hiddenOn(string | array $livewireComponents): static
{
$this->hidden(static function (HasTable $livewire) use ($livewireComponents): bool {
foreach (Arr::wrap($livewireComponents) as $livewireComponent) {
if ($livewire instanceof $livewireComponent) {
return true;
}
}
return false;
});
return $this;
}
public function visible(bool | Closure $condition = true): static
{
$this->isVisible = $condition;
return $this;
}
/**
* @param string | array<string> $livewireComponents
*/
public function visibleOn(string | array $livewireComponents): static
{
$this->visible(static function (HasTable $livewire) use ($livewireComponents): bool {
foreach (Arr::wrap($livewireComponents) as $livewireComponent) {
if ($livewire instanceof $livewireComponent) {
return true;
}
}
return false;
});
return $this;
}
public function isHidden(): bool
{
if ($this->evaluate($this->isHidden)) {
return true;
}
return ! $this->evaluate($this->isVisible);
}
public function isVisible(): bool
{
return ! $this->isHidden();
}
}
@@ -0,0 +1,31 @@
<?php
namespace Filament\Tables\Filters\Concerns;
use Closure;
trait CanResetState
{
/**
* @var array<string, mixed>
*/
protected array | Closure | null $resetState = null;
/**
* @param array<string, mixed> | Closure | null $state
*/
public function resetState(array | Closure | null $state): static
{
$this->resetState = $state;
return $this;
}
/**
* @return array<string, mixed>
*/
public function getResetState(): array
{
return $this->evaluate($this->resetState) ?? [];
}
}
@@ -0,0 +1,61 @@
<?php
namespace Filament\Tables\Filters\Concerns;
use Closure;
trait CanSpanColumns
{
/**
* @var array<string, int | string | Closure | null> | int | string | Closure | null
*/
protected array | int | string | Closure | null $columnSpan = 1;
/**
* @var array<string, int | string | Closure | null> | int | string | Closure | null
*/
protected array | int | string | Closure | null $columnStart = null;
/**
* @param array<string, int | string | Closure | null> | int | string | Closure | null $span
*/
public function columnSpan(array | int | string | Closure | null $span): static
{
$this->columnSpan = $span;
return $this;
}
public function columnSpanFull(): static
{
$this->columnSpan(['default' => 'full']);
return $this;
}
/**
* @param array<string, int | string | Closure | null> | int | string | Closure | null $start
*/
public function columnStart(array | int | string | Closure | null $start): static
{
$this->columnStart = $start;
return $this;
}
/**
* @return array<string, int | string | Closure | null> | int | string | Closure | null
*/
public function getColumnSpan(): array | int | string | Closure | null
{
return $this->columnSpan;
}
/**
* @return array<string, int | string | Closure | null> | int | string | Closure | null
*/
public function getColumnStart(): array | int | string | Closure | null
{
return $this->columnStart;
}
}

Some files were not shown because too many files have changed in this diff Show More