🆙 Add cms i using 🆙

This commit is contained in:
2025-11-25 22:42:56 +01:00
parent 94704e0925
commit d44196149e
35591 changed files with 3601123 additions and 0 deletions
@@ -0,0 +1,863 @@
<?php
namespace Filament\Actions;
use Closure;
use Filament\Actions\Concerns\HasTooltip;
use Filament\Actions\Enums\ActionStatus;
use Filament\Support\Components\Contracts\HasEmbeddedView;
use Filament\Support\Components\ViewComponent;
use Filament\Support\Concerns\HasBadge;
use Filament\Support\Concerns\HasBadgeTooltip;
use Filament\Support\Concerns\HasColor;
use Filament\Support\Concerns\HasExtraAttributes;
use Filament\Support\Concerns\HasIcon;
use Filament\Support\Concerns\HasIconPosition;
use Filament\Support\Concerns\HasIconSize;
use Filament\Support\Exceptions\Cancel;
use Filament\Support\Exceptions\Halt;
use Filament\Support\View\Concerns\CanGenerateBadgeHtml;
use Filament\Support\View\Concerns\CanGenerateButtonHtml;
use Filament\Support\View\Concerns\CanGenerateDropdownItemHtml;
use Filament\Support\View\Concerns\CanGenerateIconButtonHtml;
use Filament\Support\View\Concerns\CanGenerateLinkHtml;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Js;
use Illuminate\Support\Str;
use Illuminate\View\ComponentAttributeBag;
use Livewire\Drawer\Utils;
class Action extends ViewComponent implements Arrayable
{
use CanGenerateBadgeHtml;
use CanGenerateButtonHtml;
use CanGenerateDropdownItemHtml;
use CanGenerateIconButtonHtml;
use CanGenerateLinkHtml;
use Concerns\BelongsToGroup;
use Concerns\BelongsToLivewire;
use Concerns\BelongsToSchemaComponent;
use Concerns\BelongsToTable;
use Concerns\CanBeAuthorized;
use Concerns\CanBeBooted;
use Concerns\CanBeDisabled;
use Concerns\CanBeHidden;
use Concerns\CanBeLabeledFrom;
use Concerns\CanBeMounted;
use Concerns\CanBeOutlined;
use Concerns\CanBeRateLimited;
use Concerns\CanBeSorted;
use Concerns\CanCallParentAction;
use Concerns\CanClose;
use Concerns\CanDeselectRecordsAfterCompletion;
use Concerns\CanDispatchEvent;
use Concerns\CanFetchSelectedRecords;
use Concerns\CanNotify;
use Concerns\CanOpenModal;
use Concerns\CanOpenUrl;
use Concerns\CanRedirect;
use Concerns\CanRequireConfirmation;
use Concerns\CanSubmitForm;
use Concerns\CanUseDatabaseTransactions;
use Concerns\HasAction;
use Concerns\HasArguments;
use Concerns\HasData;
use Concerns\HasExtraModalWindowAttributes;
use Concerns\HasGroupedIcon;
use Concerns\HasInfolist;
use Concerns\HasKeyBindings;
use Concerns\HasLabel;
use Concerns\HasLifecycleHooks;
use Concerns\HasMountableArguments;
use Concerns\HasName;
use Concerns\HasParentActions;
use Concerns\HasSchema;
use Concerns\HasSize;
use Concerns\HasTableIcon;
use Concerns\HasWizard;
use Concerns\InteractsWithRecord;
use Concerns\InteractsWithSelectedRecords;
use HasBadge;
use HasBadgeTooltip;
use HasColor;
use HasExtraAttributes;
use HasIcon;
use HasIconPosition;
use HasIconSize;
use HasTooltip;
protected bool | Closure $isBulk = false;
public const BADGE_VIEW = 'filament::components.badge';
public const BUTTON_VIEW = 'filament::components.button.index';
public const GROUPED_VIEW = 'filament::components.dropdown.list.item';
public const ICON_BUTTON_VIEW = 'filament::components.icon-button';
public const LINK_VIEW = 'filament::components.link';
protected string $evaluationIdentifier = 'action';
protected string $viewIdentifier = 'action';
protected ?string $livewireTarget = null;
protected string | Closure | null $alpineClickHandler = null;
protected bool | Closure $shouldMarkAsRead = false;
protected bool | Closure $shouldMarkAsUnread = false;
protected ?int $nestingIndex = null;
protected ?ActionStatus $status = null;
final public function __construct(?string $name)
{
$this->name($name);
}
public static function make(?string $name = null): static
{
$static = app(static::class, [
'name' => $name ?? static::getDefaultName(),
]);
$static->configure();
return $static;
}
protected function setUp(): void
{
parent::setUp();
$this->defaultView(static::BUTTON_VIEW);
}
public function markAsRead(bool | Closure $condition = true): static
{
$this->shouldMarkAsRead = $condition;
return $this;
}
public function markAsUnread(bool | Closure $condition = true): static
{
$this->shouldMarkAsUnread = $condition;
return $this;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'name' => $this->getName(),
'color' => $this->getColor(),
'event' => $this->getEvent(),
'eventData' => $this->getEventData(),
'dispatchDirection' => $this->getDispatchDirection(),
'dispatchToComponent' => $this->getDispatchToComponent(),
'extraAttributes' => $this->getExtraAttributes(),
'icon' => $this->getIcon(),
'iconPosition' => $this->getIconPosition(),
'iconSize' => $this->getIconSize(),
'isOutlined' => $this->isOutlined(),
'isDisabled' => $this->isDisabled(),
'label' => $this->getLabel(),
'shouldClose' => $this->shouldClose(),
'shouldMarkAsRead' => $this->shouldMarkAsRead(),
'shouldMarkAsUnread' => $this->shouldMarkAsUnread(),
'shouldOpenUrlInNewTab' => $this->shouldOpenUrlInNewTab(),
'shouldPostToUrl' => $this->shouldPostToUrl(),
'size' => $this->getSize(),
'tooltip' => $this->getTooltip(),
'url' => $this->getUrl(),
'view' => $this->getView(),
];
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): static
{
$static = static::make($data['name']);
$view = match ($data['view'] ?? null) {
'filament-actions::button-action' => static::BUTTON_VIEW,
'filament-actions::grouped-action' => static::GROUPED_VIEW,
'filament-actions::icon-button-action' => static::ICON_BUTTON_VIEW,
'filament-actions::link-action' => static::LINK_VIEW,
default => $data['view'] ?? null,
};
if (filled($view) && static::isViewSafe($view)) {
$static->view($view);
}
if (filled($size = $data['size'] ?? null)) {
$static->size($size);
}
$static->close($data['shouldClose'] ?? false);
$static->color($data['color'] ?? null);
$static->disabled($data['isDisabled'] ?? false);
match ($data['dispatchDirection'] ?? null) {
'self' => $static->dispatchSelf($data['event'] ?? null, $data['eventData'] ?? []),
'to' => $static->dispatchTo($data['dispatchToComponent'] ?? null, $data['event'] ?? null, $data['eventData'] ?? []),
default => $static->dispatch($data['event'] ?? null, $data['eventData'] ?? [])
};
$static->extraAttributes($data['extraAttributes'] ?? []);
$static->icon($data['icon'] ?? null);
$static->iconPosition($data['iconPosition'] ?? null);
$static->iconSize($data['iconSize'] ?? null);
$static->label($data['label'] ?? null);
$static->markAsRead($data['shouldMarkAsRead'] ?? false);
$static->markAsUnread($data['shouldMarkAsUnread'] ?? false);
$static->outlined($data['isOutlined'] ?? false);
$static->postToUrl($data['shouldPostToUrl'] ?? false);
$static->url($data['url'] ?? null, $data['shouldOpenUrlInNewTab'] ?? false);
$static->tooltip($data['tooltip'] ?? null);
return $static;
}
public function isBadge(): bool
{
return $this->getView() === static::BADGE_VIEW;
}
public function badge(string | int | float | Closure | null $badge = null): static
{
if (func_num_args() === 0) {
/** @phpstan-ignore-next-line */
return $this->view(static::BADGE_VIEW);
}
$this->badge = $badge;
return $this;
}
public function button(): static
{
return $this->view(static::BUTTON_VIEW);
}
public function isButton(): bool
{
return $this->getView() === static::BUTTON_VIEW;
}
public function grouped(): static
{
return $this->view(static::GROUPED_VIEW);
}
public function iconButton(): static
{
return $this->view(static::ICON_BUTTON_VIEW);
}
public function isIconButton(): bool
{
return $this->getView() === static::ICON_BUTTON_VIEW;
}
public function link(): static
{
return $this->view(static::LINK_VIEW);
}
public function isLink(): bool
{
return $this->getView() === static::LINK_VIEW;
}
public function alpineClickHandler(string | Closure | null $handler): static
{
$this->alpineClickHandler = $handler;
$this->livewireClickHandlerEnabled(blank($handler));
return $this;
}
public static function getDefaultName(): ?string
{
return null;
}
public function getLivewireClickHandler(): ?string
{
if (! $this->isLivewireClickHandlerEnabled()) {
return null;
}
if (is_string($this->action)) {
return $this->action;
}
if ($event = $this->getLivewireEventClickHandler()) {
return $event;
}
if (filled($handler = $this->getParentActionCallLivewireClickHandler())) {
$handler .= '(';
$handler .= Js::from($this->getArguments());
$handler .= ')';
return $handler;
}
if ($this->canAccessSelectedRecords()) {
return null;
}
return $this->getJsClickHandler();
}
public function getLivewireEventClickHandler(): ?string
{
$event = $this->getEvent();
if (blank($event)) {
return null;
}
$arguments = '';
if ($component = $this->getDispatchToComponent()) {
$arguments .= Js::from($component)->toHtml();
$arguments .= ', ';
}
$arguments .= Js::from($event)->toHtml();
if ($this->getEventData()) {
$arguments .= ', ';
$arguments .= Js::from($this->getEventData())->toHtml();
}
return match ($this->getDispatchDirection()) {
'self' => "\$dispatchSelf($arguments)",
'to' => "\$dispatchTo($arguments)",
default => "\$dispatch($arguments)"
};
}
public function getAlpineClickHandler(): ?string
{
if (filled($handler = $this->evaluate($this->alpineClickHandler))) {
return $handler;
}
if ($this->shouldClose()) {
return 'close()';
}
if ($this->shouldMarkAsRead()) {
return 'markAsRead()';
}
if ($this->shouldMarkAsUnread()) {
return 'markAsUnread()';
}
if (! $this->canAccessSelectedRecords()) {
return null;
}
return $this->getJsClickHandler();
}
public function livewireTarget(?string $target): static
{
$this->livewireTarget = $target;
return $this;
}
public function getLivewireTarget(): ?string
{
if (filled($this->livewireTarget)) {
return $this->livewireTarget;
}
if (! $this->canAccessSelectedRecords()) {
return null;
}
return $this->getJsClickHandler();
}
/**
* @deprecated Use `extraAttributes()` instead.
*
* @param array<mixed> $attributes
*/
public function withAttributes(array $attributes): static
{
return $this->extraAttributes($attributes);
}
public function getLivewireCallMountedActionName(): string
{
return 'callMountedAction';
}
/**
* @deprecated Use `getJsClickHandler()` instead.
*/
protected function getJavaScriptClickHandler(): ?string
{
return $this->getJsClickHandler();
}
protected function getJsClickHandler(): ?string
{
if ($this->shouldClose()) {
return null;
}
$argumentsParameter = '';
if (count($arguments = $this->getArguments())) {
$argumentsParameter .= ', ';
$argumentsParameter .= Js::from($arguments);
}
$contextParameter = '';
if (count($context = $this->getContext())) {
$contextParameter .= ', ';
$contextParameter .= Js::from($context);
if ($argumentsParameter === '') {
$argumentsParameter = ', {}';
}
}
return "mountAction('{$this->getName()}'{$argumentsParameter}{$contextParameter})";
}
/**
* @return array<string, mixed>
*/
public function getContext(): array
{
$context = [];
$table = $this->getTable();
if ($table) {
$context['table'] = true;
}
$record = $this->getRecord();
if ($record && (
(! $table)
|| (! $record instanceof Model)
|| blank($table->getModel())
|| ($record::class === $table->getModel())
) && filled($recordKey = $this->resolveRecordKey($record))) {
$context['recordKey'] = $recordKey;
}
if ($table && $this->isBulk()) {
$context['bulk'] = true;
}
if (filled($schemaComponentKey = ($this->getSchemaContainer() ?? $this->getSchemaComponent())?->getKey())) {
$context['schemaComponent'] = $schemaComponentKey;
}
return $context;
}
/**
* @return array<mixed>
*/
protected function resolveDefaultClosureDependencyForEvaluationByName(string $parameterName): array
{
return match ($parameterName) {
'arguments' => [$this->getArguments()],
'data' => [$this->getData()],
'livewire' => [$this->getLivewire()],
'model' => [$this->getModel()],
'mountedActions' => [$this->getLivewire()->getMountedActions()],
'record' => [$this->getRecord()],
'selectedRecords', 'records' => [$this->getIndividuallyAuthorizedSelectedRecords()],
'selectedRecordsQuery', 'recordsQuery' => [$this->getSelectedRecordsQuery()],
'schema' => [$this->getSchemaContainer()],
'schemaComponent', 'component' => [$this->getSchemaComponent()],
'schemaOperation', 'context', 'operation' => [$this->getSchemaContainer()?->getOperation() ?? $this->getSchemaComponent()?->getContainer()->getOperation()],
'schemaGet', 'get' => [$this->getSchemaComponent()->makeGetUtility()],
'schemaSet', 'set' => [$this->getSchemaComponent()->makeSetUtility()],
'schemaComponentState', 'state' => [$this->getSchemaComponent()->getState()],
'table' => [$this->getTable()],
default => parent::resolveDefaultClosureDependencyForEvaluationByName($parameterName),
};
}
/**
* @return array<mixed>
*/
protected function resolveDefaultClosureDependencyForEvaluationByType(string $parameterType): array
{
$record = $this->getRecord();
return match ($parameterType) {
Builder::class => [$this->getSelectedRecordsQuery()],
EloquentCollection::class, Collection::class => [$this->getIndividuallyAuthorizedSelectedRecords()],
Model::class, ($record instanceof Model) ? $record::class : null => [$record],
default => parent::resolveDefaultClosureDependencyForEvaluationByType($parameterType),
};
}
public function shouldClearRecordAfter(): bool
{
if (! ($this->record instanceof Model)) {
return false;
}
return ! $this->record->exists;
}
public function clearRecordAfter(): void
{
if (! $this->shouldClearRecordAfter()) {
return;
}
$this->record(null);
}
/**
* @param array<string, mixed> $parameters
*/
public function call(array $parameters = []): mixed
{
try {
return $this->evaluate($this->getActionFunction(), $parameters);
} finally {
if ($this->shouldDeselectRecordsAfterCompletion()) {
$this->getLivewire()->deselectAllTableRecords();
}
}
}
public function cancel(bool $shouldRollBackDatabaseTransaction = false): void
{
throw (new Cancel)->rollBackDatabaseTransaction($shouldRollBackDatabaseTransaction);
}
public function halt(bool $shouldRollBackDatabaseTransaction = false): void
{
throw (new Halt)->rollBackDatabaseTransaction($shouldRollBackDatabaseTransaction);
}
/**
* @deprecated Use `halt()` instead.
*/
public function hold(): void
{
$this->halt();
}
public function success(): void
{
$this->status = ActionStatus::Success;
}
public function failure(): void
{
$this->status = ActionStatus::Failure;
}
public function getStatus(): ActionStatus
{
if ($this->status) {
return $this->status;
}
if (! $this->canAccessSelectedRecords()) {
return ActionStatus::Success;
}
if ($this->successfulSelectedRecordsCount === $this->totalSelectedRecordsCount) {
return ActionStatus::Success;
}
return ActionStatus::Failure;
}
public function bulk(bool | Closure $condition = true): static
{
$this->isBulk = $condition;
return $this;
}
public function isBulk(): bool
{
return (bool) $this->evaluate($this->isBulk);
}
/**
* @param view-string $view
*/
protected static function isViewSafe(string $view): bool
{
return Str::startsWith($view, 'filament::');
}
public function shouldMarkAsRead(): bool
{
return (bool) $this->evaluate($this->shouldMarkAsRead);
}
public function shouldMarkAsUnread(): bool
{
return (bool) $this->evaluate($this->shouldMarkAsUnread);
}
public function nestingIndex(?int $index): static
{
$this->nestingIndex = $index;
return $this;
}
public function getNestingIndex(): ?int
{
return $this->nestingIndex;
}
public function renderModal(): View
{
return view('filament-actions::action-modal', [
'action' => $this,
]);
}
public function toModalHtmlable(): Htmlable
{
return new HtmlString(Utils::insertAttributesIntoHtmlRoot($this->renderModal()->render(), [
'wire:partial' => "action-modals.{$this->getNestingIndex()}",
]));
}
public function toHtml(): string
{
if (($this instanceof HasEmbeddedView) && (! $this->hasView())) {
return $this->toEmbeddedHtml();
}
return match ($this->getView()) {
static::BADGE_VIEW => $this->toBadgeHtml(),
static::BUTTON_VIEW => $this->toButtonHtml(),
static::GROUPED_VIEW => $this->toGroupedHtml(),
static::ICON_BUTTON_VIEW => $this->toIconButtonHtml(),
static::LINK_VIEW => $this->toLinkHtml(),
default => $this->render()->render(),
};
}
protected function toBadgeHtml(): string
{
$isDisabled = $this->isDisabled();
$url = $this->getUrl();
$shouldPostToUrl = $this->shouldPostToUrl();
return $this->generateBadgeHtml(
attributes: (new ComponentAttributeBag([
'action' => $shouldPostToUrl ? $url : null,
'method' => $shouldPostToUrl ? 'post' : null,
'wire:click' => $this->getLivewireClickHandler(),
'wire:target' => $this->getLivewireTarget(),
'x-on:click' => $this->getAlpineClickHandler(),
]))
->merge($this->getExtraAttributes(), escape: false)
->class(['fi-ac-badge-action']),
color: $this->getColor(),
form: $this->getFormToSubmit(),
formId: $this->getFormId(),
href: ($isDisabled || $shouldPostToUrl) ? null : $url,
icon: $this->getIcon(default: $this->getTable() ? $this->getTableIcon() : null),
iconPosition: $this->getIconPosition(),
iconSize: $this->getIconSize(),
isDisabled: $isDisabled,
keyBindings: $this->getKeyBindings(),
label: $this->getLabel(),
size: $this->getSize(),
tag: $url ? $shouldPostToUrl ? 'form' : 'a' : 'button',
target: ($url && $this->shouldOpenUrlInNewTab()) ? '_blank' : null,
tooltip: $this->getTooltip(),
type: $this->canSubmitForm() ? 'submit' : 'button',
);
}
protected function toButtonHtml(): string
{
$isDisabled = $this->isDisabled();
$url = $this->getUrl();
$shouldPostToUrl = $this->shouldPostToUrl();
return $this->generateButtonHtml(
attributes: (new ComponentAttributeBag([
'action' => $shouldPostToUrl ? $url : null,
'method' => $shouldPostToUrl ? 'post' : null,
'wire:click' => $this->getLivewireClickHandler(),
'wire:target' => $this->getLivewireTarget(),
'x-on:click' => $this->getAlpineClickHandler(),
]))
->merge($this->getExtraAttributes(), escape: false)
->class(['fi-ac-btn-action']),
badge: $this->getBadge(),
badgeColor: $this->getBadgeColor(),
color: $this->getColor(),
form: $this->getFormToSubmit(),
formId: $this->getFormId(),
href: ($isDisabled || $shouldPostToUrl) ? null : $url,
icon: $this->getIcon(default: $this->getTable() ? $this->getTableIcon() : null),
iconPosition: $this->getIconPosition(),
iconSize: $this->getIconSize(),
isDisabled: $isDisabled,
isLabelSrOnly: $this->isLabelHidden(),
isOutlined: $this->isOutlined(),
keyBindings: $this->getKeyBindings(),
label: $this->getLabel(),
labeledFromBreakpoint: $this->getLabeledFromBreakpoint(),
size: $this->getSize(),
tag: $url ? $shouldPostToUrl ? 'form' : 'a' : 'button',
target: ($url && $this->shouldOpenUrlInNewTab()) ? '_blank' : null,
tooltip: $this->getTooltip(),
type: $this->canSubmitForm() ? 'submit' : 'button',
);
}
protected function toGroupedHtml(): string
{
$isDisabled = $this->isDisabled();
$url = $this->getUrl();
$shouldPostToUrl = $this->shouldPostToUrl();
return $this->generateDropdownItemHtml(
attributes: (new ComponentAttributeBag([
'action' => $shouldPostToUrl ? $url : null,
'method' => $shouldPostToUrl ? 'post' : null,
'wire:click' => $this->getLivewireClickHandler(),
'wire:target' => $this->getLivewireTarget(),
'x-on:click' => $this->getAlpineClickHandler(),
]))
->merge($this->getExtraAttributes(), escape: false)
->class(['fi-ac-grouped-action']),
badge: $this->getBadge(),
badgeColor: $this->getBadgeColor(),
badgeTooltip: $this->getBadgeTooltip(),
color: $this->getColor(),
href: ($isDisabled || $shouldPostToUrl) ? null : $url,
icon: $this->getIcon(default: $this->getGroupedIcon()),
iconSize: $this->getIconSize(),
isDisabled: $isDisabled,
keyBindings: $this->getKeyBindings(),
label: $this->getLabel(),
tag: $url ? $shouldPostToUrl ? 'form' : 'a' : 'button',
target: ($url && $this->shouldOpenUrlInNewTab()) ? '_blank' : null,
tooltip: $this->getTooltip(),
type: $this->canSubmitForm() ? 'submit' : 'button',
);
}
protected function toIconButtonHtml(): string
{
$isDisabled = $this->isDisabled();
$url = $this->getUrl();
$shouldPostToUrl = $this->shouldPostToUrl();
return $this->generateIconButtonHtml(
attributes: (new ComponentAttributeBag([
'action' => $shouldPostToUrl ? $url : null,
'method' => $shouldPostToUrl ? 'post' : null,
'wire:click' => $this->getLivewireClickHandler(),
'wire:target' => $this->getLivewireTarget(),
'x-on:click' => $this->getAlpineClickHandler(),
]))
->merge($this->getExtraAttributes(), escape: false)
->class(['fi-ac-icon-btn-action']),
badge: $this->getBadge(),
badgeColor: $this->getBadgeColor(),
color: $this->getColor(),
form: $this->getFormToSubmit(),
formId: $this->getFormId(),
href: ($isDisabled || $shouldPostToUrl) ? null : $url,
icon: $this->getIcon(default: $this->getTable() ? $this->getTableIcon() : null),
iconSize: $this->getIconSize(),
isDisabled: $isDisabled,
keyBindings: $this->getKeyBindings(),
label: $this->getLabel(),
size: $this->getSize(),
tag: $url ? $shouldPostToUrl ? 'form' : 'a' : 'button',
target: ($url && $this->shouldOpenUrlInNewTab()) ? '_blank' : null,
tooltip: $this->getTooltip(),
type: $this->canSubmitForm() ? 'submit' : 'button',
);
}
protected function toLinkHtml(): string
{
$isDisabled = $this->isDisabled();
$url = $this->getUrl();
$shouldPostToUrl = $this->shouldPostToUrl();
return $this->generateLinkHtml(
attributes: (new ComponentAttributeBag([
'action' => $shouldPostToUrl ? $url : null,
'method' => $shouldPostToUrl ? 'post' : null,
'wire:click' => $this->getLivewireClickHandler(),
'wire:target' => $this->getLivewireTarget(),
'x-on:click' => $this->getAlpineClickHandler(),
]))
->merge($this->getExtraAttributes(), escape: false)
->class(['fi-ac-link-action']),
badge: $this->getBadge(),
badgeColor: $this->getBadgeColor(),
color: $this->getColor(),
form: $this->getFormToSubmit(),
formId: $this->getFormId(),
href: ($isDisabled || $shouldPostToUrl) ? null : $url,
icon: $this->getIcon(default: $this->getTable() ? $this->getTableIcon() : null),
iconPosition: $this->getIconPosition(),
iconSize: $this->getIconSize(),
isDisabled: $isDisabled,
isLabelSrOnly: $this->isLabelHidden(),
keyBindings: $this->getKeyBindings(),
label: $this->getLabel(),
size: $this->getSize(),
tag: $url ? $shouldPostToUrl ? 'form' : 'a' : 'button',
target: ($url && $this->shouldOpenUrlInNewTab()) ? '_blank' : null,
tooltip: $this->getTooltip(),
type: $this->canSubmitForm() ? 'submit' : 'button',
);
}
public function getClone(): static
{
return clone $this;
}
}
@@ -0,0 +1,719 @@
<?php
namespace Filament\Actions;
use BackedEnum;
use Closure;
use Filament\Actions\Concerns\InteractsWithRecord;
use Filament\Actions\View\ActionsIconAlias;
use Filament\Support\Components\Contracts\HasEmbeddedView;
use Filament\Support\Components\ViewComponent;
use Filament\Support\Concerns\HasBadge;
use Filament\Support\Concerns\HasBadgeTooltip;
use Filament\Support\Concerns\HasColor;
use Filament\Support\Concerns\HasExtraAttributes;
use Filament\Support\Concerns\HasIcon;
use Filament\Support\Concerns\HasIconPosition;
use Filament\Support\Concerns\HasIconSize;
use Filament\Support\Concerns\HasTooltip;
use Filament\Support\Enums\Width;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
use Filament\Support\View\Concerns\CanGenerateBadgeHtml;
use Filament\Support\View\Concerns\CanGenerateButtonHtml;
use Filament\Support\View\Concerns\CanGenerateDropdownItemHtml;
use Filament\Support\View\Concerns\CanGenerateIconButtonHtml;
use Filament\Support\View\Concerns\CanGenerateLinkHtml;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use Illuminate\View\ComponentAttributeBag;
use LogicException;
class ActionGroup extends ViewComponent implements Arrayable, HasEmbeddedView
{
use CanGenerateBadgeHtml;
use CanGenerateButtonHtml;
use CanGenerateDropdownItemHtml;
use CanGenerateIconButtonHtml;
use CanGenerateLinkHtml;
use Concerns\BelongsToGroup;
use Concerns\BelongsToLivewire;
use Concerns\BelongsToSchemaComponent;
use Concerns\BelongsToTable;
use Concerns\CanBeHidden {
isHidden as baseIsHidden;
}
use Concerns\CanBeLabeledFrom;
use Concerns\CanBeOutlined;
use Concerns\HasDropdown;
use Concerns\HasGroupedIcon;
use Concerns\HasLabel;
use Concerns\HasSize;
use HasBadge;
use HasBadgeTooltip;
use HasColor;
use HasExtraAttributes;
use HasIcon {
HasIcon::getIcon as getBaseIcon;
}
use HasIconPosition;
use HasIconSize;
use HasTooltip;
use InteractsWithRecord;
public const BADGE_VIEW = 'filament::components.badge';
public const BUTTON_VIEW = 'filament::components.button.index';
public const BUTTON_GROUP_VIEW = 'filament::components.button.group';
public const GROUPED_VIEW = 'filament::components.dropdown.list.item';
public const ICON_BUTTON_VIEW = 'filament::components.icon-button';
public const LINK_VIEW = 'filament::components.link';
/**
* @var array<Action | ActionGroup>
*/
protected array $actions;
/**
* @var array<string, Action>
*/
protected array $flatActions;
protected string $evaluationIdentifier = 'group';
protected string $viewIdentifier = 'group';
/**
* @var view-string
*/
protected string $triggerView;
/**
* @var view-string | Closure | null
*/
protected string | Closure | null $defaultTriggerView = null;
/**
* @var array<array<mixed> | Closure>
*/
protected array $extraDropdownAttributes = [];
/**
* @param array<Action | ActionGroup> $actions
*/
public function __construct(array $actions)
{
$this->actions($actions);
}
/**
* @param array<Action | ActionGroup> $actions
*/
public static function make(array $actions): static
{
$static = app(static::class, ['actions' => $actions]);
$static->configure();
return $static;
}
protected function setUp(): void
{
parent::setUp();
$this->defaultTriggerView(static::ICON_BUTTON_VIEW);
}
/**
* @param array<Action | ActionGroup> $actions
*/
public function actions(array $actions): static
{
$this->actions = [];
$this->flatActions = [];
foreach ($actions as $action) {
$action->group($this);
if ($action instanceof ActionGroup) {
$action->defaultDropdownPlacement('right-top');
$this->flatActions = [
...$this->flatActions,
...$action->getFlatActions(),
];
} else {
$this->flatActions[$action->getName()] = $action;
}
$this->actions[] = $action;
}
return $this;
}
public function isBadge(): bool
{
return $this->getTriggerView() === static::BADGE_VIEW;
}
public function badge(string | int | float | Closure | null $badge = null): static
{
if (func_num_args() === 0) {
/** @phpstan-ignore-next-line */
return $this->triggerView(static::BADGE_VIEW);
}
$this->badge = $badge;
return $this;
}
public function button(): static
{
return $this->triggerView(static::BUTTON_VIEW);
}
public function buttonGroup(): static
{
return $this->triggerView(static::BUTTON_GROUP_VIEW);
}
public function isButton(): bool
{
return $this->getTriggerView() === static::BUTTON_VIEW;
}
public function isButtonGroup(): bool
{
return $this->getTriggerView() === static::BUTTON_GROUP_VIEW;
}
public function grouped(): static
{
return $this->triggerView(static::GROUPED_VIEW);
}
public function iconButton(): static
{
return $this->triggerView(static::ICON_BUTTON_VIEW);
}
public function isIconButton(): bool
{
return $this->getTriggerView() === static::ICON_BUTTON_VIEW;
}
public function link(): static
{
return $this->triggerView(static::LINK_VIEW);
}
public function isLink(): bool
{
return $this->getTriggerView() === static::LINK_VIEW;
}
public function getLabel(): string | Htmlable | null
{
$label = $this->evaluate($this->label) ?? __('filament-actions::group.trigger.label');
return is_string($label) && $this->shouldTranslateLabel
? __($label)
: $label;
}
/**
* @return array<Action | ActionGroup>
*/
public function getActions(): array
{
return array_map(
fn (Action | ActionGroup $action) => match (true) {
$action instanceof Action => $action->defaultView($this->isButtonGroup() ? $action::BUTTON_VIEW : $action::GROUPED_VIEW),
$action instanceof ActionGroup => $action->defaultTriggerView($this->isButtonGroup() ? $action::BUTTON_VIEW : $action::GROUPED_VIEW),
},
$this->actions,
);
}
/**
* @return array<string, Action>
*/
public function getFlatActions(): array
{
return $this->flatActions;
}
public function hasNonBulkAction(): bool
{
foreach ($this->getFlatActions() as $action) {
if (! $action->isBulk()) {
return true;
}
}
return false;
}
public function getIcon(): string | BackedEnum | Htmlable
{
return $this->getBaseIcon() ?? FilamentIcon::resolve(ActionsIconAlias::ACTION_GROUP) ?? Heroicon::EllipsisVertical;
}
public function isHidden(): bool
{
if ($this->baseIsHidden()) {
return true;
}
foreach ($this->getActions() as $action) {
if ($action->isHiddenInGroup()) {
continue;
}
return false;
}
return true;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'actions' => collect($this->getActions())->toArray(),
'color' => $this->getColor(),
'dropdownMaxHeight' => $this->getDropdownMaxHeight(),
'dropdownOffset' => $this->getDropdownOffset(),
'dropdownPlacement' => $this->getDropdownPlacement(),
'dropdownWidth' => $this->getDropdownWidth(),
'extraAttributes' => $this->getExtraAttributes(),
'hasDropdown' => $this->hasDropdown(),
'hasDropdownFlip' => $this->hasDropdownFlip(),
'hasDropdownTeleport' => $this->hasDropdownTeleport(),
'icon' => $this->getIcon(),
'iconPosition' => $this->getIconPosition(),
'iconSize' => $this->getIconSize(),
'isOutlined' => $this->isOutlined(),
'label' => $this->getLabel(),
'size' => $this->getSize(),
'tooltip' => $this->getTooltip(),
'triggerView' => $this->getTriggerView(),
'view' => $this->getView(),
];
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): static
{
$static = static::make(
array_map(
fn (array $action): Action | ActionGroup => match (array_key_exists('actions', $action)) {
true => ActionGroup::fromArray($action),
false => Action::fromArray($action),
},
$data['actions'] ?? [],
),
);
$view = $data['view'] ?? null;
if (filled($view) && ($static->getView() !== $view) && static::isViewSafe($view)) {
$static->view($view);
}
$triggerView = $data['triggerView'] ?? null;
if (filled($triggerView) && ($static->getTriggerView() !== $triggerView) && static::isViewSafe($triggerView)) {
$static->triggerView($triggerView);
}
if (filled($size = $data['size'] ?? null)) {
$static->size($size);
}
$static->color($data['color'] ?? null);
$static->dropdown($data['hasDropdown'] ?? false);
$static->dropdownFlip($data['hasDropdownFlip'] ?? false);
$static->dropdownTeleport($data['hasDropdownTeleport'] ?? false);
$static->dropdownMaxHeight($data['dropdownMaxHeight'] ?? null);
$static->dropdownOffset($data['dropdownOffset'] ?? null);
$static->dropdownPlacement($data['dropdownPlacement'] ?? null);
$static->dropdownWidth($data['dropdownWidth'] ?? null);
$static->extraAttributes($data['extraAttributes'] ?? []);
$static->icon($data['icon'] ?? null);
$static->iconPosition($data['iconPosition'] ?? null);
$static->iconSize($data['iconSize'] ?? null);
$static->label($data['label'] ?? null);
$static->outlined($data['isOutlined'] ?? null);
$static->tooltip($data['tooltip'] ?? null);
return $static;
}
/**
* @param view-string $view
*/
protected static function isViewSafe(string $view): bool
{
return Str::startsWith($view, 'filament::');
}
protected function resolveDefaultClosureDependencyForEvaluationByName(string $parameterName): array
{
return match ($parameterName) {
'livewire' => [$this->getLivewire()],
'model' => [$this->getModel()],
'mountedActions' => [$this->getLivewire()->getMountedActions()],
'record' => [$this->getRecord()],
'schema' => [$this->getSchemaContainer()],
'schemaComponent', 'component' => [$this->getSchemaComponent()],
'schemaOperation', 'context', 'operation' => [$this->getSchemaContainer()?->getOperation() ?? $this->getSchemaComponent()?->getContainer()->getOperation()],
'schemaGet', 'get' => [$this->getSchemaComponent()->makeGetUtility()],
'schemaComponentState', 'state' => [$this->getSchemaComponent()->getState()],
'table' => [$this->getTable()],
default => parent::resolveDefaultClosureDependencyForEvaluationByName($parameterName),
};
}
protected function resolveDefaultClosureDependencyForEvaluationByType(string $parameterType): array
{
$record = $this->getRecord();
return match ($parameterType) {
Model::class, ($record instanceof Model) ? $record::class : null => [$record],
default => parent::resolveDefaultClosureDependencyForEvaluationByType($parameterType),
};
}
public function toEmbeddedHtml(): string
{
if ($this->isButtonGroup()) {
ob_start(); ?>
<div class="fi-btn-group">
<?php foreach ($this->getActions() as $action) { ?>
<?php if ($action->isVisible()) { ?>
<?= $action->toHtml() ?>
<?php } ?>
<?php } ?>
</div>
<?php return ob_get_clean();
}
if (! $this->hasDropdown()) {
return collect($this->getActions())
->filter(fn (Action | ActionGroup $action): bool => $action->isVisible())
->map(fn (Action | ActionGroup $action): string => $action->toHtml())
->implode('');
}
$actionLists = [];
$singleActions = [];
foreach ($this->getActions() as $action) {
if ($action->isHidden()) {
continue;
}
if ($action instanceof ActionGroup && (! $action->hasDropdown())) {
if (count($singleActions)) {
$actionLists[] = $singleActions;
$singleActions = [];
}
$actionLists[] = array_filter(
$action->getActions(),
fn ($action): bool => $action->isVisible(),
);
} else {
$singleActions[] = $action;
}
}
if (count($singleActions)) {
$actionLists[] = $singleActions;
}
$maxHeight = $this->getDropdownMaxHeight();
$width = $this->getDropdownWidth();
$panelAttributes = (new ComponentAttributeBag)
->class([
'fi-dropdown-panel',
($width instanceof Width) ? "fi-width-{$width->value}" : (is_string($width) ? $width : ''),
'fi-scrollable' => $maxHeight,
])
->style([
"max-height: {$maxHeight}" => $maxHeight,
]);
ob_start(); ?>
<div
x-data="filamentDropdown"
<?= $this->getExtraDropdownAttributeBag()->class(['fi-dropdown'])->toHtml() ?>
>
<div
x-on:mousedown="toggle"
class="fi-dropdown-trigger"
>
<?= $this->toTriggerHtml() ?>
</div>
<div
x-cloak
x-float.placement.<?= $this->getDropdownPlacement() ?? 'bottom-start' ?><?= $this->hasDropdownFlip() ? '.flip' : '' ?><?= $this->hasDropdownTeleport() ? '.teleport' : '' ?>.offset="{ offset: <?= $this->getDropdownOffset() ?? 8 ?> }"
x-ref="panel"
x-transition:enter-start="fi-opacity-0"
x-transition:leave-end="fi-opacity-0"
<?= $panelAttributes->toHtml() ?>
>
<?php foreach ($actionLists as $actions) { ?>
<div class="fi-dropdown-list">
<?php foreach ($actions as $action) { ?>
<?= $action->toHtml() ?>
<?php } ?>
</div>
<?php } ?>
</div>
</div>
<?php return ob_get_clean();
}
/**
* @param view-string | null $view
*/
public function triggerView(?string $view): static
{
if ($view === null) {
return $this;
}
$this->triggerView = $view;
return $this;
}
/**
* @param view-string | Closure | null $view
*/
public function defaultTriggerView(string | Closure | null $view): static
{
$this->defaultTriggerView = $view;
return $this;
}
/**
* @return view-string
*/
public function getTriggerView(): string
{
if (isset($this->triggerView)) {
return $this->triggerView;
}
if (filled($defaultView = $this->getDefaultTriggerView())) {
return $defaultView;
}
throw new LogicException('Class [' . static::class . '] extends [' . ActionGroup::class . '] but does not have a [$triggerView] property defined.');
}
/**
* @return view-string | null
*/
public function getDefaultTriggerView(): ?string
{
return $this->evaluate($this->defaultTriggerView);
}
public function toTriggerHtml(): string
{
return match ($this->getTriggerView()) {
static::BADGE_VIEW => $this->toBadgeTriggerHtml(),
static::BUTTON_VIEW => $this->toButtonTriggerHtml(),
static::GROUPED_VIEW => $this->toGroupedTriggerHtml(),
static::ICON_BUTTON_VIEW => $this->toIconButtonTriggerHtml(),
static::LINK_VIEW => $this->toLinkTriggerHtml(),
default => $this->renderTrigger()->render(),
};
}
protected function toBadgeTriggerHtml(): string
{
return $this->generateBadgeHtml(
attributes: (new ComponentAttributeBag)
->merge($this->getExtraAttributes(), escape: false)
->class(['fi-ac-badge-group']),
color: $this->getColor(),
icon: $this->getIcon(),
iconPosition: $this->getIconPosition(),
iconSize: $this->getIconSize(),
label: $this->getLabel(),
size: $this->getSize(),
tag: 'button',
tooltip: $this->getTooltip(),
);
}
protected function toButtonTriggerHtml(): string
{
return $this->generateButtonHtml(
attributes: (new ComponentAttributeBag)
->merge($this->getExtraAttributes(), escape: false)
->class(['fi-ac-btn-group']),
badge: $this->getBadge(),
badgeColor: $this->getBadgeColor(),
color: $this->getColor(),
icon: $this->getIcon(),
iconPosition: $this->getIconPosition(),
iconSize: $this->getIconSize(),
isLabelSrOnly: $this->isLabelHidden(),
isOutlined: $this->isOutlined(),
label: $this->getLabel(),
labeledFromBreakpoint: $this->getLabeledFromBreakpoint(),
size: $this->getSize(),
tag: 'button',
tooltip: $this->getTooltip(),
);
}
protected function toGroupedTriggerHtml(): string
{
return $this->generateDropdownItemHtml(
attributes: (new ComponentAttributeBag)
->merge($this->getExtraAttributes(), escape: false)
->class(['fi-ac-grouped-group']),
badge: $this->getBadge(),
badgeColor: $this->getBadgeColor(),
badgeTooltip: $this->getBadgeTooltip(),
color: $this->getColor(),
icon: $this->getIcon(),
iconSize: $this->getIconSize(),
label: $this->getLabel(),
tag: 'button',
tooltip: $this->getTooltip(),
);
}
protected function toIconButtonTriggerHtml(): string
{
return $this->generateIconButtonHtml(
attributes: (new ComponentAttributeBag)
->merge($this->getExtraAttributes(), escape: false)
->class(['fi-ac-icon-btn-group']),
badge: $this->getBadge(),
badgeColor: $this->getBadgeColor(),
color: $this->getColor(),
icon: $this->getIcon(),
iconSize: $this->getIconSize(),
label: $this->getLabel(),
size: $this->getSize(),
tag: 'button',
tooltip: $this->getTooltip(),
);
}
protected function toLinkTriggerHtml(): string
{
return $this->generateLinkHtml(
attributes: (new ComponentAttributeBag)
->merge($this->getExtraAttributes(), escape: false)
->class(['fi-ac-link-group']),
badge: $this->getBadge(),
badgeColor: $this->getBadgeColor(),
color: $this->getColor(),
icon: $this->getIcon(),
iconPosition: $this->getIconPosition(),
iconSize: $this->getIconSize(),
isLabelSrOnly: $this->isLabelHidden(),
label: $this->getLabel(),
size: $this->getSize(),
tag: 'button',
tooltip: $this->getTooltip(),
);
}
public function renderTrigger(): View
{
return view(
$this->getTriggerView(),
[
'attributes' => new ComponentAttributeBag,
...$this->extractPublicMethods(),
...(isset($this->viewIdentifier) ? [$this->viewIdentifier => $this] : []),
...$this->viewData,
],
);
}
public function getClone(): static
{
$clone = clone $this;
$clone->cloneActions();
return $clone;
}
protected function cloneActions(): void
{
$this->actions = array_map(
fn (Action | ActionGroup $action): Action | ActionGroup => $action->getClone()->group($this),
$this->actions,
);
$this->flatActions = array_map(
fn (Action $action): Action => $action->getClone()->group($this),
$this->flatActions,
);
}
/**
* @param array<mixed> | Closure $attributes
*/
public function extraDropdownAttributes(array | Closure $attributes, bool $merge = false): static
{
if ($merge) {
$this->extraDropdownAttributes[] = $attributes;
} else {
$this->extraDropdownAttributes = [$attributes];
}
return $this;
}
/**
* @return array<mixed>
*/
public function getExtraDropdownAttributes(): array
{
$temporaryAttributeBag = new ComponentAttributeBag;
foreach ($this->extraDropdownAttributes as $extraDropdownAttributes) {
$temporaryAttributeBag = $temporaryAttributeBag->merge($this->evaluate($extraDropdownAttributes), escape: false);
}
return $temporaryAttributeBag->getAttributes();
}
public function getExtraDropdownAttributeBag(): ComponentAttributeBag
{
return new ComponentAttributeBag($this->getExtraDropdownAttributes());
}
}
@@ -0,0 +1,17 @@
<?php
namespace Filament\Actions;
use Attribute;
use Stringable;
#[Attribute]
class ActionName implements Stringable
{
public function __construct(protected string $name) {}
public function __toString(): string
{
return $this->name;
}
}
@@ -0,0 +1,46 @@
<?php
namespace Filament\Actions;
use Filament\Actions\Testing\TestsActions;
use Filament\Support\Assets\Js;
use Filament\Support\Facades\FilamentAsset;
use Illuminate\Routing\Router;
use Livewire\Features\SupportTesting\Testable;
use Spatie\LaravelPackageTools\Package;
use Spatie\LaravelPackageTools\PackageServiceProvider;
class ActionsServiceProvider extends PackageServiceProvider
{
public function configurePackage(Package $package): void
{
$package
->name('filament-actions')
->hasCommands([
Commands\MakeExporterCommand::class,
Commands\MakeImporterCommand::class,
])
->hasMigrations([
'create_imports_table',
'create_exports_table',
'create_failed_import_rows_table',
])
->hasRoutes('web')
->hasTranslations()
->hasViews();
}
public function packageRegistered(): void
{
app(Router::class)->middlewareGroup('filament.actions', ['web']);
}
public function packageBooted(): void
{
FilamentAsset::register([
Js::make('actions', __DIR__ . '/../dist/index.js'),
], 'filament/actions');
Testable::mixin(new TestsActions);
}
}
@@ -0,0 +1,319 @@
<?php
namespace Filament\Actions;
use Closure;
use Filament\Actions\Concerns\CanCustomizeProcess;
use Filament\Forms\Components\Select;
use Filament\Schemas\Schema;
use Filament\Support\Enums\Width;
use Filament\Tables\Table;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\Relation;
use function Filament\Support\generate_search_column_expression;
use function Filament\Support\generate_search_term_expression;
class AssociateAction extends Action
{
use CanCustomizeProcess;
protected ?Closure $modifyRecordSelectUsing = null;
protected ?Closure $modifyRecordSelectOptionsQueryUsing = null;
protected bool | Closure $canAssociateAnother = true;
protected bool | Closure $isRecordSelectPreloaded = false;
/**
* @var array<string> | Closure | null
*/
protected array | Closure | null $recordSelectSearchColumns = null;
protected bool | Closure | null $isSearchForcedCaseInsensitive = null;
protected bool | Closure $isMultiple = false;
public static function getDefaultName(): ?string
{
return 'associate';
}
protected function setUp(): void
{
parent::setUp();
$this->label(__('filament-actions::associate.single.label'));
$this->modalHeading(fn (): string => __('filament-actions::associate.single.modal.heading', ['label' => $this->getTitleCaseModelLabel()]));
$this->modalSubmitActionLabel(__('filament-actions::associate.single.modal.actions.associate.label'));
$this->modalWidth(Width::Large);
$this->extraModalFooterActions(function (): array {
return $this->canAssociateAnother ? [
$this->makeModalSubmitAction('associateAnother', arguments: ['another' => true])
->label(__('filament-actions::associate.single.modal.actions.associate_another.label')),
] : [];
});
$this->successNotificationTitle(__('filament-actions::associate.single.notifications.associated.title'));
$this->defaultColor('gray');
$this->schema(fn (): array => [$this->getRecordSelect()]);
$this->action(function (array $arguments, array $data, Schema $schema, Table $table): void {
/** @var HasMany | MorphMany $relationship */
$relationship = Relation::noConstraints(fn () => $table->getRelationship());
$record = $relationship->getQuery()->find($data['recordId']);
foreach (($this->isMultiple ? $record : [$record]) as $record) {
if ($record instanceof Model) {
$this->record($record);
}
/** @var BelongsTo $inverseRelationship */
$inverseRelationship = $table->getInverseRelationshipFor($record);
$this->process(function () use ($inverseRelationship, $record, $relationship): void {
$inverseRelationship->associate($relationship->getParent());
$record->save();
}, [
'inverseRelationship' => $inverseRelationship,
'relationship' => $relationship,
]);
}
if ($arguments['another'] ?? false) {
$this->callAfter();
$this->sendSuccessNotification();
$this->record(null);
$schema->fill();
$this->halt();
return;
}
$this->success();
});
}
public function recordSelect(?Closure $callback): static
{
$this->modifyRecordSelectUsing = $callback;
return $this;
}
public function recordSelectOptionsQuery(?Closure $callback): static
{
$this->modifyRecordSelectOptionsQueryUsing = $callback;
return $this;
}
public function associateAnother(bool | Closure $condition = true): static
{
$this->canAssociateAnother = $condition;
return $this;
}
/**
* @deprecated Use `associateAnother()` instead.
*/
public function disableAssociateAnother(bool | Closure $condition = true): static
{
$this->associateAnother(fn (AssociateAction $action): bool => ! $action->evaluate($condition));
return $this;
}
public function preloadRecordSelect(bool | Closure $condition = true): static
{
$this->isRecordSelectPreloaded = $condition;
return $this;
}
public function canAssociateAnother(): bool
{
return (bool) $this->evaluate($this->canAssociateAnother);
}
public function isRecordSelectPreloaded(): bool
{
return (bool) $this->evaluate($this->isRecordSelectPreloaded);
}
/**
* @param array<string> | Closure | null $columns
*/
public function recordSelectSearchColumns(array | Closure | null $columns): static
{
$this->recordSelectSearchColumns = $columns;
return $this;
}
/**
* @return array<string> | null
*/
public function getRecordSelectSearchColumns(): ?array
{
return $this->evaluate($this->recordSelectSearchColumns);
}
public function multiple(bool | Closure $condition = true): static
{
$this->isMultiple = $condition;
return $this;
}
public function isMultiple(): bool
{
return (bool) $this->evaluate($this->isMultiple);
}
public function getRecordSelect(): Select
{
$table = $this->getTable();
$getOptions = function (int $optionsLimit, ?string $search = null, ?array $searchColumns = []) use ($table): array {
/** @var HasMany | MorphMany $relationship */
$relationship = Relation::noConstraints(fn () => $table->getRelationship());
$relationshipQuery = $relationship->getQuery();
if ($this->modifyRecordSelectOptionsQueryUsing) {
$relationshipQuery = $this->evaluate($this->modifyRecordSelectOptionsQueryUsing, [
'query' => $relationshipQuery,
'search' => $search,
]) ?? $relationshipQuery;
}
if (! isset($relationshipQuery->getQuery()->limit)) {
$relationshipQuery->limit($optionsLimit);
}
$titleAttribute = $this->getRecordTitleAttribute();
$titleAttribute = filled($titleAttribute) ? $relationshipQuery->qualifyColumn($titleAttribute) : null;
if (filled($search) && ($searchColumns || filled($titleAttribute))) {
/** @var Connection $databaseConnection */
$databaseConnection = $relationshipQuery->getConnection();
$isForcedCaseInsensitive = $this->isSearchForcedCaseInsensitive();
$search = generate_search_term_expression($search, $isForcedCaseInsensitive, $databaseConnection);
$searchColumns ??= [$titleAttribute];
$isFirst = true;
$relationshipQuery->where(function (Builder $query) use ($databaseConnection, $isFirst, $isForcedCaseInsensitive, $searchColumns, $search): Builder {
foreach ($searchColumns as $searchColumn) {
$whereClause = $isFirst ? 'where' : 'orWhere';
$query->{$whereClause}(
generate_search_column_expression($query->qualifyColumn($searchColumn), $isForcedCaseInsensitive, $databaseConnection),
'like',
"%{$search}%",
);
$isFirst = false;
}
return $query;
});
}
$relationCountHash = $relationship->getRelationCountHash(incrementJoinCount: false);
if ($relationship instanceof MorphMany) {
$relationshipQuery->whereNotMorphedTo($table->getInverseRelationship(), $relationship->getParent());
} else {
$relationshipQuery
->whereDoesntHave($table->getInverseRelationship(), fn (Builder $query): Builder => $query->where(
// https://github.com/filamentphp/filament/issues/8067
$relationship->getParent()->getTable() === $relationship->getRelated()->getTable() ?
"{$relationCountHash}.{$relationship->getParent()->getKeyName()}" :
$relationship->getParent()->getQualifiedKeyName(),
$relationship->getParent()->getKey(),
));
}
if (
filled($titleAttribute) &&
(! $this->hasCustomRecordTitle()) &&
($this->hasCustomRecordTitleAttribute() || (! $this->getTable()->hasCustomRecordTitle()))
) {
if (empty($relationshipQuery->getQuery()->orders)) {
$relationshipQuery->orderBy($titleAttribute);
}
return $relationshipQuery
->pluck($titleAttribute, $relationship->getModel()->getQualifiedKeyName())
->all();
}
return $relationshipQuery
->get()
->mapWithKeys(fn (Model $record): array => [$record->getKey() => $this->getRecordTitle($record)])
->all();
};
$select = Select::make('recordId')
->label(__('filament-actions::associate.single.modal.fields.record_id.label'))
->required()
->multiple($this->isMultiple())
->searchable($this->getRecordSelectSearchColumns() ?? true)
->getSearchResultsUsing(static fn (Select $component, string $search): array => $getOptions(optionsLimit: $component->getOptionsLimit(), search: $search, searchColumns: $component->getSearchColumns()))
->getOptionLabelUsing(function ($value) use ($table): string {
$relationship = Relation::noConstraints(fn () => $table->getRelationship());
return $this->getRecordTitle($relationship->getQuery()->find($value));
})
->getOptionLabelsUsing(function (array $values) use ($table): array {
$relationship = Relation::noConstraints(fn () => $table->getRelationship());
return $relationship->getQuery()->findMany($values)
->mapWithKeys(fn (Model $record): array => [$record->getKey() => $this->getRecordTitle($record)])
->all();
})
->options(fn (Select $component): array => $this->isRecordSelectPreloaded() ? $getOptions(optionsLimit: $component->getOptionsLimit()) : [])
->hiddenLabel();
if ($this->modifyRecordSelectUsing) {
$select = $this->evaluate($this->modifyRecordSelectUsing, [
'select' => $select,
]);
}
return $select;
}
public function forceSearchCaseInsensitive(bool | Closure | null $condition = true): static
{
$this->isSearchForcedCaseInsensitive = $condition;
return $this;
}
public function isSearchForcedCaseInsensitive(): ?bool
{
return $this->evaluate($this->isSearchForcedCaseInsensitive);
}
}
@@ -0,0 +1,329 @@
<?php
namespace Filament\Actions;
use Closure;
use Filament\Actions\Concerns\CanCustomizeProcess;
use Filament\Forms\Components\Select;
use Filament\Schemas\Schema;
use Filament\Support\Enums\Width;
use Filament\Support\Services\RelationshipJoiner;
use Filament\Tables\Table;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Arr;
use function Filament\Support\generate_search_column_expression;
use function Filament\Support\generate_search_term_expression;
class AttachAction extends Action
{
use CanCustomizeProcess;
protected ?Closure $modifyRecordSelectUsing = null;
protected ?Closure $modifyRecordSelectOptionsQueryUsing = null;
protected bool | Closure $canAttachAnother = true;
protected bool | Closure $isRecordSelectPreloaded = false;
protected bool | Closure $isMultiple = false;
/**
* @var array<string> | Closure | null
*/
protected array | Closure | null $recordSelectSearchColumns = null;
protected bool | Closure | null $isSearchForcedCaseInsensitive = null;
public static function getDefaultName(): ?string
{
return 'attach';
}
protected function setUp(): void
{
parent::setUp();
$this->label(__('filament-actions::attach.single.label'));
$this->modalHeading(fn (): string => __('filament-actions::attach.single.modal.heading', ['label' => $this->getTitleCaseModelLabel()]));
$this->modalSubmitActionLabel(__('filament-actions::attach.single.modal.actions.attach.label'));
$this->modalWidth(Width::Large);
$this->extraModalFooterActions(function (): array {
return $this->canAttachAnother() ? [
$this->makeModalSubmitAction('attachAnother', ['another' => true])
->label(__('filament-actions::attach.single.modal.actions.attach_another.label')),
] : [];
});
$this->successNotificationTitle(__('filament-actions::attach.single.notifications.attached.title'));
$this->defaultColor('gray');
$this->schema(fn (): array => [$this->getRecordSelect()]);
$this->action(function (array $arguments, array $data, Schema $schema, Table $table): void {
/** @var BelongsToMany $relationship */
$relationship = Relation::noConstraints(fn () => $table->getRelationship());
$relationshipQuery = app(RelationshipJoiner::class)->prepareQueryForNoConstraints($relationship);
$isMultiple = is_array($data['recordId']);
$record = $relationshipQuery
->{$isMultiple ? 'whereIn' : 'where'}($relationship->getQualifiedRelatedKeyName(), $data['recordId'])
->{$isMultiple ? 'get' : 'first'}();
if ($record instanceof Model) {
$this->record($record);
}
$this->process(function () use ($data, $record, $relationship): void {
$relationship->attach(
$record,
Arr::only($data, $relationship->getPivotColumns()),
);
}, [
'relationship' => $relationship,
]);
if ($arguments['another'] ?? false) {
$this->callAfter();
$this->sendSuccessNotification();
$this->record(null);
$schema->fill();
$this->halt();
return;
}
$this->success();
});
}
public function recordSelect(?Closure $callback): static
{
$this->modifyRecordSelectUsing = $callback;
return $this;
}
public function recordSelectOptionsQuery(?Closure $callback): static
{
$this->modifyRecordSelectOptionsQueryUsing = $callback;
return $this;
}
public function attachAnother(bool | Closure $condition = true): static
{
$this->canAttachAnother = $condition;
return $this;
}
/**
* @deprecated Use `attachAnother()` instead.
*/
public function disableAttachAnother(bool | Closure $condition = true): static
{
$this->attachAnother(fn (AttachAction $action): bool => ! $action->evaluate($condition));
return $this;
}
public function preloadRecordSelect(bool | Closure $condition = true): static
{
$this->isRecordSelectPreloaded = $condition;
return $this;
}
public function canAttachAnother(): bool
{
return (bool) $this->evaluate($this->canAttachAnother);
}
public function isRecordSelectPreloaded(): bool
{
return (bool) $this->evaluate($this->isRecordSelectPreloaded);
}
public function multiple(bool | Closure $condition = true): static
{
$this->isMultiple = $condition;
return $this;
}
public function isMultiple(): bool
{
return (bool) $this->evaluate($this->isMultiple);
}
/**
* @param array<string> | Closure | null $columns
*/
public function recordSelectSearchColumns(array | Closure | null $columns): static
{
$this->recordSelectSearchColumns = $columns;
return $this;
}
/**
* @return array<string> | null
*/
public function getRecordSelectSearchColumns(): ?array
{
return $this->evaluate($this->recordSelectSearchColumns);
}
public function getRecordSelect(): Select
{
$table = $this->getTable();
$getOptions = function (int $optionsLimit, ?string $search = null, ?array $searchColumns = []) use ($table): array {
/** @var BelongsToMany $relationship */
$relationship = Relation::noConstraints(fn () => $table->getRelationship());
$relationshipQuery = app(RelationshipJoiner::class)->prepareQueryForNoConstraints($relationship);
if ($this->modifyRecordSelectOptionsQueryUsing) {
$relationshipQuery = $this->evaluate($this->modifyRecordSelectOptionsQueryUsing, [
'query' => $relationshipQuery,
'search' => $search,
]) ?? $relationshipQuery;
}
if (! isset($relationshipQuery->getQuery()->limit)) {
$relationshipQuery->limit($optionsLimit);
}
$titleAttribute = $this->getRecordTitleAttribute();
$titleAttribute = filled($titleAttribute) ? $relationshipQuery->qualifyColumn($titleAttribute) : null;
if (filled($search) && ($searchColumns || filled($titleAttribute))) {
/** @var Connection $databaseConnection */
$databaseConnection = $relationshipQuery->getConnection();
$isForcedCaseInsensitive = $this->isSearchForcedCaseInsensitive();
$search = generate_search_term_expression($search, $isForcedCaseInsensitive, $databaseConnection);
$searchColumns ??= [$titleAttribute];
$isFirst = true;
$relationshipQuery->where(function (Builder $query) use ($databaseConnection, $isFirst, $isForcedCaseInsensitive, $searchColumns, $search): Builder {
foreach ($searchColumns as $searchColumn) {
$whereClause = $isFirst ? 'where' : 'orWhere';
$query->{$whereClause}(
generate_search_column_expression($query->qualifyColumn($searchColumn), $isForcedCaseInsensitive, $databaseConnection),
'like',
"%{$search}%",
);
$isFirst = false;
}
return $query;
});
}
$relationCountHash = $relationship->getRelationCountHash(incrementJoinCount: false);
$relationshipQuery
->when(
! $table->allowsDuplicates(),
fn (Builder $query): Builder => $query->whereDoesntHave(
$table->getInverseRelationship(),
fn (Builder $query): Builder => $query->where(
// https://github.com/filamentphp/filament/issues/8067
$relationship->getParent()->getTable() === $relationship->getRelated()->getTable() ?
"{$relationCountHash}.{$relationship->getParent()->getKeyName()}" :
$relationship->getParent()->getQualifiedKeyName(),
$relationship->getParent()->getKey(),
),
),
);
if (
filled($titleAttribute) &&
(! $this->hasCustomRecordTitle()) &&
($this->hasCustomRecordTitleAttribute() || (! $this->getTable()->hasCustomRecordTitle()))
) {
if (empty($relationshipQuery->getQuery()->orders)) {
$relationshipQuery->orderBy($titleAttribute);
}
return $relationshipQuery
->pluck($titleAttribute, $relationship->getQualifiedRelatedKeyName())
->all();
}
$relatedKeyName = $relationship->getRelatedKeyName();
return $relationshipQuery
->get()
->mapWithKeys(fn (Model $record): array => [$record->{$relatedKeyName} => $this->getRecordTitle($record)])
->all();
};
$select = Select::make('recordId')
->label(__('filament-actions::attach.single.modal.fields.record_id.label'))
->required()
->multiple($this->isMultiple())
->searchable($this->getRecordSelectSearchColumns() ?? true)
->getSearchResultsUsing(static fn (Select $component, string $search): array => $getOptions(optionsLimit: $component->getOptionsLimit(), search: $search, searchColumns: $component->getSearchColumns()))
->getOptionLabelUsing(function ($value) use ($table): string {
$relationship = Relation::noConstraints(fn () => $table->getRelationship());
$relationshipQuery = app(RelationshipJoiner::class)->prepareQueryForNoConstraints($relationship);
return $this->getRecordTitle($relationshipQuery->find($value));
})
->getOptionLabelsUsing(function (array $values) use ($table): array {
$relationship = Relation::noConstraints(fn () => $table->getRelationship());
$relationshipQuery = app(RelationshipJoiner::class)->prepareQueryForNoConstraints($relationship);
return $relationshipQuery->find($values)
->mapWithKeys(fn (Model $record): array => [$record->getKey() => $this->getRecordTitle($record)])
->all();
})
->options(fn (Select $component): array => $this->isRecordSelectPreloaded() ? $getOptions(optionsLimit: $component->getOptionsLimit()) : [])
->hiddenLabel();
if ($this->modifyRecordSelectUsing) {
$select = $this->evaluate($this->modifyRecordSelectUsing, [
'select' => $select,
]);
}
return $select;
}
public function forceSearchCaseInsensitive(bool | Closure | null $condition = true): static
{
$this->isSearchForcedCaseInsensitive = $condition;
return $this;
}
public function isSearchForcedCaseInsensitive(): ?bool
{
return $this->evaluate($this->isSearchForcedCaseInsensitive);
}
}
@@ -0,0 +1,26 @@
<?php
namespace Filament\Actions;
class BulkAction extends Action
{
protected function setUp(): void
{
parent::setUp();
$this->bulk();
$this->accessSelectedRecords();
}
/**
* @return array<mixed>
*/
public function getExtraAttributes(): array
{
return [
'x-cloak' => true,
'x-show' => 'getSelectedRecordsCount()',
...parent::getExtraAttributes(),
];
}
}
@@ -0,0 +1,39 @@
<?php
namespace Filament\Actions;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\View\TablesIconAlias;
class BulkActionGroup extends ActionGroup
{
protected function setUp(): void
{
parent::setUp();
$this->label(__('filament-tables::table.actions.open_bulk_actions.label'));
$this->icon(FilamentIcon::resolve(TablesIconAlias::ACTIONS_OPEN_BULK_ACTIONS) ?? Heroicon::EllipsisVertical);
$this->defaultColor('gray');
$this->button();
$this->dropdownPlacement('bottom-start');
$this->labeledFrom('sm');
}
/**
* @return array<mixed>
*/
public function getExtraDropdownAttributes(): array
{
return [
'x-cloak' => true,
'x-show' => 'getSelectedRecordsCount()',
...parent::getExtraAttributes(),
];
}
}
@@ -0,0 +1,17 @@
<?php
namespace Filament\Actions;
/**
* @deprecated Use `\Filament\Actions\Action` instead, with the `button()` method.
* @see Action
*/
class ButtonAction extends Action
{
protected function setUp(): void
{
parent::setUp();
$this->button();
}
}
@@ -0,0 +1,234 @@
<?php
namespace Filament\Actions\Commands\FileGenerators;
use Filament\Actions\Exports\ExportColumn;
use Filament\Actions\Exports\Exporter;
use Filament\Actions\Exports\Models\Export;
use Filament\Support\Commands\Concerns\CanReadModelSchemas;
use Filament\Support\Commands\FileGenerators\ClassGenerator;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Number;
use Illuminate\Support\Str;
use Nette\PhpGenerator\ClassType;
use Nette\PhpGenerator\Literal;
use Nette\PhpGenerator\Method;
use Nette\PhpGenerator\Property;
use function Filament\Support\get_model_label;
class ExporterClassGenerator extends ClassGenerator
{
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 [
$this->getExtends(),
Export::class,
ExportColumn::class,
Number::class,
$this->getModelFqn(),
];
}
public function getBasename(): string
{
return class_basename($this->getFqn());
}
public function getExtends(): string
{
return Exporter::class;
}
protected function addPropertiesToClass(ClassType $class): void
{
$this->addModelPropertyToClass($class);
}
protected function addMethodsToClass(ClassType $class): void
{
$this->addGetColumnsMethodToClass($class);
$this->addGetCompletedNotificationBodyMethodToClass($class);
}
protected function addModelPropertyToClass(ClassType $class): void
{
$property = $class->addProperty('model', new Literal("{$this->simplifyFqn($this->getModelFqn())}::class"))
->setProtected()
->setStatic()
->setType('?string');
$this->configureModelProperty($property);
}
protected function configureModelProperty(Property $property): void {}
protected function addGetColumnsMethodToClass(ClassType $class): void
{
$method = $class->addMethod('getColumns')
->setPublic()
->setStatic()
->setReturnType('array')
->setBody(<<<PHP
return [
{$this->outputExporterColumns()}
];
PHP);
$this->configureGetColumnsMethod($method);
}
public function outputExporterColumns(): string
{
$columns = $this->getExporterColumns();
if (empty($columns)) {
return '//';
}
return implode(PHP_EOL . ' ', $columns);
}
/**
* @return array<string>
*/
public function getExporterColumns(): array
{
if (! $this->isGenerated()) {
return [];
}
$model = $this->getModelFqn();
if (blank($model)) {
return [];
}
if (! class_exists($model)) {
return [];
}
$schema = $this->getModelSchema($model);
$table = $this->getModelTable($model);
$columns = [];
foreach ($schema->getColumns($table) as $column) {
$columnName = $column['name'];
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}";
}
}
$columnData = [];
if (in_array($columnName, [
'id',
'sku',
'uuid',
])) {
$columnData['label'] = [Str::upper($columnName)];
}
$columns[$columnName] = $columnData;
}
return array_map(
function (array $columnData, string $columnName): string {
$column = (string) new Literal("{$this->simplifyFqn(ExportColumn::class)}::make(?)", [$columnName]);
foreach ($columnData as $methodName => $parameters) {
$column .= new Literal(PHP_EOL . " ->{$methodName}(...?:)", [$parameters]);
}
return "{$column},";
},
$columns,
array_keys($columns),
);
}
protected function configureGetColumnsMethod(Method $method): void {}
protected function addGetCompletedNotificationBodyMethodToClass(ClassType $class): void
{
$method = $class->addMethod('getCompletedNotificationBody')
->setPublic()
->setStatic()
->setReturnType('string')
->setBody(<<<PHP
\$body = 'Your {$this->getModelLabel()} export has completed and ' . {$this->simplifyFqn(Number::class)}::format(\$export->successful_rows) . ' ' . str('row')->plural(\$export->successful_rows) . ' exported.';
if (\$failedRowsCount = \$export->getFailedRowsCount()) {
\$body .= ' ' . {$this->simplifyFqn(Number::class)}::format(\$failedRowsCount) . ' ' . str('row')->plural(\$failedRowsCount) . ' failed to export.';
}
return \$body;
PHP);
$method->addParameter('export')
->setType(Export::class);
$this->configureGetCompletedNotificationBodyMethod($method);
}
protected function configureGetCompletedNotificationBodyMethod(Method $method): void {}
public function getFqn(): string
{
return $this->fqn;
}
public function getModelLabel(): string
{
return get_model_label($this->getModelFqn());
}
/**
* @return class-string<Model>
*/
public function getModelFqn(): string
{
return $this->modelFqn;
}
public function isGenerated(): bool
{
return $this->isGenerated;
}
}
@@ -0,0 +1,323 @@
<?php
namespace Filament\Actions\Commands\FileGenerators;
use Filament\Actions\Imports\ImportColumn;
use Filament\Actions\Imports\Importer;
use Filament\Actions\Imports\Models\Import;
use Filament\Support\Commands\Concerns\CanReadModelSchemas;
use Filament\Support\Commands\FileGenerators\ClassGenerator;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Number;
use Illuminate\Support\Str;
use Nette\PhpGenerator\ClassType;
use Nette\PhpGenerator\Literal;
use Nette\PhpGenerator\Method;
use Nette\PhpGenerator\Property;
use function Filament\Support\get_model_label;
class ImporterClassGenerator extends ClassGenerator
{
use CanReadModelSchemas;
/**
* @param class-string<Model> $modelFqn
*/
final public function __construct(
protected string $fqn,
protected string $modelFqn,
protected string $resolutionMode,
protected ?string $resolutionColumn,
protected bool $isGenerated,
) {}
public function getNamespace(): string
{
return $this->extractNamespace($this->getFqn());
}
/**
* @return array<string>
*/
public function getImports(): array
{
return [
$this->getExtends(),
Import::class,
ImportColumn::class,
Number::class,
$this->getModelFqn(),
];
}
public function getBasename(): string
{
return class_basename($this->getFqn());
}
public function getExtends(): string
{
return Importer::class;
}
protected function addPropertiesToClass(ClassType $class): void
{
$this->addModelPropertyToClass($class);
}
protected function addMethodsToClass(ClassType $class): void
{
$this->addGetColumnsMethodToClass($class);
$this->addResolveRecordMethodToClass($class);
$this->addGetCompletedNotificationBodyMethodToClass($class);
}
protected function addModelPropertyToClass(ClassType $class): void
{
$property = $class->addProperty('model', new Literal("{$this->simplifyFqn($this->getModelFqn())}::class"))
->setProtected()
->setStatic()
->setType('?string');
$this->configureModelProperty($property);
}
protected function configureModelProperty(Property $property): void {}
protected function addGetColumnsMethodToClass(ClassType $class): void
{
$method = $class->addMethod('getColumns')
->setPublic()
->setStatic()
->setReturnType('array')
->setBody(<<<PHP
return [
{$this->outputImporterColumns()}
];
PHP);
$this->configureGetColumnsMethod($method);
}
public function outputImporterColumns(): string
{
$columns = $this->getImporterColumns();
if (empty($columns)) {
return '//';
}
return implode(PHP_EOL . ' ', $columns);
}
/**
* @return array<string>
*/
public function getImporterColumns(): array
{
if (! $this->isGenerated()) {
return [];
}
$model = $this->getModelFqn();
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;
}
$columnName = $column['name'];
if (str($columnName)->is([
app($model)->getKeyName(),
'created_at',
'deleted_at',
'updated_at',
'*_token',
])) {
continue;
}
$columnData = [];
if (in_array($columnName, [
'id',
'sku',
'uuid',
])) {
$columnData['label'] = [Str::upper($columnName)];
}
if (! $column['nullable']) {
$columnData['rules'][0][] = 'required';
$columnData['requiredMapping'] = [];
}
if (str($columnName)->contains(['email'])) {
$columnData['rules'][0][] = 'email';
}
$type = $this->parseColumnType($column);
if (
str($columnName)->endsWith('_id') &&
filled($guessedRelationshipName = $this->guessBelongsToRelationshipName($columnName, $model))
) {
$columnName = $guessedRelationshipName;
$columnData['relationship'] = [];
} elseif (in_array($type['name'], [
'boolean',
])) {
$columnData['rules'][0][] = 'boolean';
$columnData['boolean'] = [];
} elseif (in_array($type['name'], [
'date',
])) {
$columnData['rules'][0][] = 'date';
} elseif (in_array($type['name'], [
'datetime',
'timestamp',
])) {
$columnData['rules'][0][] = 'datetime';
} elseif (in_array($type['name'], [
'integer',
'decimal',
'float',
'double',
'money',
])) {
$columnData['rules'][0][] = 'integer';
$columnData['numeric'] = [];
} elseif (isset($type['length'])) {
$columnData['rules'][0][] = "max:{$type['length']}";
}
// Move rules to the end of the column definition.
if (array_key_exists('rules', $columnData)) {
$rules = $columnData['rules'];
unset($columnData['rules']);
$columnData['rules'] = $rules;
}
$columns[$columnName] = $columnData;
}
return array_map(
function (array $columnData, string $columnName): string {
$column = (string) new Literal("{$this->simplifyFqn(ImportColumn::class)}::make(?)", [$columnName]);
foreach ($columnData as $methodName => $parameters) {
$column .= new Literal(PHP_EOL . " ->{$methodName}(...?:)", [$parameters]);
}
return "{$column},";
},
$columns,
array_keys($columns),
);
}
protected function configureGetColumnsMethod(Method $method): void {}
protected function addResolveRecordMethodToClass(ClassType $class): void
{
$resolutionMode = $this->getResolutionMode();
$resolutionColumn = $this->getResolutionColumn();
$method = $class->addMethod('resolveRecord')
->setPublic()
->setReturnType(match ($resolutionMode) {
'update' => "?{$this->getModelFqn()}",
default => $this->getModelFqn(),
})
->setBody(match ($resolutionMode) {
'upsert' => new Literal(<<<PHP
return {$this->simplifyFqn($this->getModelFqn())}::firstOrNew([
? => \$this->data[?],
]);
PHP, [$resolutionColumn, $resolutionColumn]),
'update' => new Literal(<<<PHP
return {$this->simplifyFqn($this->getModelFqn())}::query()
->where(?, \$this->data[?])
->first();
PHP, [$resolutionColumn, $resolutionColumn]),
default => <<<PHP
return new {$this->simplifyFqn($this->getModelFqn())}();
PHP,
});
$this->configureResolveRecordMethod($method);
}
protected function configureResolveRecordMethod(Method $method): void {}
protected function addGetCompletedNotificationBodyMethodToClass(ClassType $class): void
{
$method = $class->addMethod('getCompletedNotificationBody')
->setPublic()
->setStatic()
->setReturnType('string')
->setBody(<<<PHP
\$body = 'Your {$this->getModelLabel()} import has completed and ' . {$this->simplifyFqn(Number::class)}::format(\$import->successful_rows) . ' ' . str('row')->plural(\$import->successful_rows) . ' imported.';
if (\$failedRowsCount = \$import->getFailedRowsCount()) {
\$body .= ' ' . {$this->simplifyFqn(Number::class)}::format(\$failedRowsCount) . ' ' . str('row')->plural(\$failedRowsCount) . ' failed to import.';
}
return \$body;
PHP);
$method->addParameter('import')
->setType(Import::class);
$this->configureGetCompletedNotificationBodyMethod($method);
}
protected function configureGetCompletedNotificationBodyMethod(Method $method): void {}
public function getFqn(): string
{
return $this->fqn;
}
public function getModelLabel(): string
{
return get_model_label($this->getModelFqn());
}
/**
* @return class-string<Model>
*/
public function getModelFqn(): string
{
return $this->modelFqn;
}
public function getResolutionMode(): string
{
return $this->resolutionMode;
}
public function getResolutionColumn(): ?string
{
return $this->resolutionColumn;
}
public function isGenerated(): bool
{
return $this->isGenerated;
}
}
@@ -0,0 +1,199 @@
<?php
namespace Filament\Actions\Commands;
use Filament\Actions\Commands\FileGenerators\ExporterClassGenerator;
use Filament\Support\Commands\Concerns\CanAskForComponentLocation;
use Filament\Support\Commands\Concerns\CanManipulateFiles;
use Filament\Support\Commands\Exceptions\FailureCommandOutput;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Stringable;
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;
#[AsCommand(name: 'make:filament-exporter', aliases: [
'filament:exporter',
])]
class MakeExporterCommand extends Command
{
use CanAskForComponentLocation;
use CanManipulateFiles;
protected $description = 'Create a new Filament exporter class';
protected $name = 'make:filament-exporter';
/**
* @var class-string<Model>
*/
protected string $modelFqn;
protected string $modelFqnEnd;
protected bool $isGenerated;
/**
* @var class-string
*/
protected string $fqn;
protected string $fqnEnd;
protected string $path;
/**
* @var array<string>
*/
protected $aliases = [
'filament:exporter',
];
/**
* @return array<InputArgument>
*/
protected function getArguments(): array
{
return [
new InputArgument(
name: 'model',
mode: InputArgument::OPTIONAL,
description: 'The name of the model to generate the exporter 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 exporter 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->configureModel();
$this->configureIsGenerated();
$this->configureLocation();
$this->createExporter();
} catch (FailureCommandOutput) {
return static::FAILURE;
}
$this->components->info("Exporter [{$this->fqn}] created successfully.");
return static::SUCCESS;
}
protected function configureModel(): void
{
if ($this->argument('model')) {
$this->modelFqnEnd = (string) str($this->argument('model'))
->trim('/')
->trim('\\')
->trim(' ')
->when(
fn (Stringable $model): bool => str($model)->endsWith('Exporter'),
fn (Stringable $model): Stringable => str($model)->beforeLast('Exporter'),
)
->studly()
->replace('/', '\\');
if (blank($this->modelFqnEnd)) {
$this->modelFqnEnd = 'Resource';
}
$modelNamespace = $this->option('model-namespace') ?? app()->getNamespace() . 'Models';
$this->modelFqn = "{$modelNamespace}\\{$this->modelFqnEnd}";
} else {
$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 exporter columns be generated from the current database columns?',
default: false,
);
}
protected function configureLocation(): void
{
[
$namespace,
$path,
] = $this->askForComponentLocation(
path: 'Exports',
question: 'Where would you like to create the exporter?',
);
$this->fqnEnd = "{$this->modelFqnEnd}Exporter";
$this->fqn = "{$namespace}\\{$this->fqnEnd}";
$this->path = (string) str("{$path}\\{$this->fqnEnd}.php")
->replace('\\', '/')
->replace('//', '/');
}
protected function createExporter(): void
{
if (! $this->option('force') && $this->checkForCollision($this->path)) {
throw new FailureCommandOutput;
}
$this->writeFile($this->path, app(ExporterClassGenerator::class, [
'fqn' => $this->fqn,
'modelFqn' => $this->modelFqn,
'isGenerated' => $this->isGenerated,
]));
}
}
@@ -0,0 +1,229 @@
<?php
namespace Filament\Actions\Commands;
use Filament\Actions\Commands\FileGenerators\ImporterClassGenerator;
use Filament\Support\Commands\Concerns\CanAskForComponentLocation;
use Filament\Support\Commands\Concerns\CanManipulateFiles;
use Filament\Support\Commands\Exceptions\FailureCommandOutput;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Stringable;
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\select;
use function Laravel\Prompts\suggest;
use function Laravel\Prompts\text;
#[AsCommand(name: 'make:filament-importer', aliases: [
'filament:importer',
])]
class MakeImporterCommand extends Command
{
use CanAskForComponentLocation;
use CanManipulateFiles;
protected $description = 'Create a new Filament importer class';
protected $name = 'make:filament-importer';
/**
* @var class-string<Model>
*/
protected string $modelFqn;
protected string $modelFqnEnd;
protected string $resolutionMode;
protected ?string $resolutionColumn = null;
protected bool $isGenerated;
/**
* @var class-string
*/
protected string $fqn;
protected string $fqnEnd;
protected string $path;
/**
* @var array<string>
*/
protected $aliases = [
'filament:importer',
];
/**
* @return array<InputArgument>
*/
protected function getArguments(): array
{
return [
new InputArgument(
name: 'model',
mode: InputArgument::OPTIONAL,
description: 'The name of the model to generate the importer 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 importer 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->configureModel();
$this->configureResolution();
$this->configureIsGenerated();
$this->configureLocation();
$this->createImporter();
} catch (FailureCommandOutput) {
return static::FAILURE;
}
$this->components->info("Importer [{$this->fqn}] created successfully.");
return static::SUCCESS;
}
protected function configureModel(): void
{
if ($this->argument('model')) {
$this->modelFqnEnd = (string) str($this->argument('model'))
->trim('/')
->trim('\\')
->trim(' ')
->when(
fn (Stringable $model): bool => str($model)->endsWith('Importer'),
fn (Stringable $model): Stringable => str($model)->beforeLast('Importer'),
)
->studly()
->replace('/', '\\');
if (blank($this->modelFqnEnd)) {
$this->modelFqnEnd = 'Resource';
}
$modelNamespace = $this->option('model-namespace') ?? app()->getNamespace() . 'Models';
$this->modelFqn = "{$modelNamespace}\\{$this->modelFqnEnd}";
} else {
$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 configureResolution(): void
{
$this->resolutionMode = select(
label: 'How would you like the importer to behave?',
options: [
'create' => 'Create records only',
'upsert' => 'Create a record if it does not exist, otherwise update it',
'update' => 'Update records only',
],
default: 'create',
);
if (in_array($this->resolutionMode, ['upsert', 'update'])) {
$this->resolutionColumn = text(
label: 'What column should be used to find existing records?',
placeholder: 'slug',
required: true,
);
}
}
protected function configureIsGenerated(): void
{
$this->isGenerated = $this->option('generate') || confirm(
label: 'Should the importer columns be generated from the current database columns?',
default: false,
);
}
protected function configureLocation(): void
{
[
$namespace,
$path,
] = $this->askForComponentLocation(
path: 'Imports',
question: 'Where would you like to create the importer?',
);
$this->fqnEnd = "{$this->modelFqnEnd}Importer";
$this->fqn = "{$namespace}\\{$this->fqnEnd}";
$this->path = (string) str("{$path}\\{$this->fqnEnd}.php")
->replace('\\', '/')
->replace('//', '/');
}
protected function createImporter(): void
{
if (! $this->option('force') && $this->checkForCollision($this->path)) {
throw new FailureCommandOutput;
}
$this->writeFile($this->path, app(ImporterClassGenerator::class, [
'fqn' => $this->fqn,
'modelFqn' => $this->modelFqn,
'resolutionMode' => $this->resolutionMode,
'resolutionColumn' => $this->resolutionColumn,
'isGenerated' => $this->isGenerated,
]));
}
}
@@ -0,0 +1,39 @@
<?php
namespace Filament\Actions\Concerns;
use Filament\Actions\ActionGroup;
trait BelongsToGroup
{
protected ?ActionGroup $group = null;
public function group(?ActionGroup $group): static
{
$this->group = $group;
return $this;
}
public function getGroup(): ?ActionGroup
{
return $this->group;
}
public function getRootGroup(): ?ActionGroup
{
$group = $this->getGroup();
while ($group) {
$parentGroup = $group->getGroup();
if (! $parentGroup) {
break;
}
$group = $parentGroup;
}
return $group;
}
}
@@ -0,0 +1,50 @@
<?php
namespace Filament\Actions\Concerns;
use Filament\Actions\Contracts\HasActions;
use Livewire\Component;
trait BelongsToLivewire
{
protected Component $livewire;
public function livewire(Component $livewire): static
{
$this->livewire = $livewire;
return $this;
}
public function getLivewire(): ?object
{
if (isset($this->livewire)) {
return $this->livewire;
}
if ($livewire = $this->getSchemaContainer()?->getLivewire()) {
return $livewire;
}
if ($livewire = $this->getSchemaComponent()?->getLivewire()) {
return $livewire;
}
if ($livewire = $this->getTable()?->getLivewire()) {
return $livewire;
}
return $this->getGroup()?->getLivewire();
}
public function getHasActionsLivewire(): ?HasActions
{
$livewire = $this->getLivewire();
if (! $livewire instanceof HasActions) {
return null;
}
return $livewire;
}
}
@@ -0,0 +1,37 @@
<?php
namespace Filament\Actions\Concerns;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Schema;
trait BelongsToSchemaComponent
{
protected ?Component $schemaComponent = null;
protected ?Schema $schemaContainer = null;
public function schemaComponent(?Component $component): static
{
$this->schemaComponent = $component;
return $this;
}
public function schemaContainer(?Schema $schema): static
{
$this->schemaContainer = $schema;
return $this;
}
public function getSchemaComponent(): ?Component
{
return $this->schemaComponent ?? $this->getSchemaContainer()?->getParentComponent() ?? $this->getGroup()?->getSchemaComponent();
}
public function getSchemaContainer(): ?Schema
{
return $this->schemaContainer ?? $this->getGroup()?->getSchemaContainer();
}
}
@@ -0,0 +1,22 @@
<?php
namespace Filament\Actions\Concerns;
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 ?? $this->getGroup()?->getTable();
}
}
@@ -0,0 +1,248 @@
<?php
namespace Filament\Actions\Concerns;
use BackedEnum;
use Closure;
use Illuminate\Auth\Access\Response;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Gate;
use LogicException;
trait CanBeAuthorized
{
protected mixed $authorization = null;
protected string | Closure | null $authorizationMessage = null;
protected bool | Closure $hasAuthorizationTooltip = false;
protected bool | Closure $hasAuthorizationNotification = false;
protected bool | string | Closure | null $authorizeIndividualRecords = null;
/**
* @param Model | class-string | array<mixed> | null $arguments
*/
public function authorize(mixed $abilities, Model | string | array | null $arguments = null): static
{
if ($abilities instanceof BackedEnum) {
$abilities = $abilities->value;
}
if (is_string($abilities) || is_array($abilities)) {
$this->authorization = [
'type' => 'all',
'abilities' => Arr::wrap($abilities),
'arguments' => Arr::wrap($arguments),
];
} else {
$this->authorization = $abilities;
}
return $this;
}
/**
* @param string | BackedEnum | array<string> $abilities
* @param Model | array<mixed> | null $arguments
*/
public function authorizeAny(string | BackedEnum | array $abilities, Model | array | null $arguments = null): static
{
if ($abilities instanceof BackedEnum) {
$abilities = $abilities->value;
}
$this->authorization = [
'type' => 'any',
'abilities' => Arr::wrap($abilities),
'arguments' => Arr::wrap($arguments),
];
return $this;
}
/**
* @param array<mixed> $arguments
* @return array<mixed>
*/
protected function parseAuthorizationArguments(array $arguments): array
{
if ($record = $this->getRecord()) {
array_unshift($arguments, $record);
} elseif ($model = $this->getModel()) {
array_unshift($arguments, $model);
}
return $arguments;
}
public function isAuthorized(): bool
{
if ($this->authorization === null) {
return $this->getHasActionsLivewire()?->getDefaultActionAuthorizationResponse($this)?->allowed() ?? true;
}
if (! is_array($this->authorization)) {
$response = $this->evaluate($this->authorization);
return match (true) {
$response instanceof Response => $response->allowed(),
default => (bool) $response,
};
}
$abilities = $this->authorization['abilities'] ?? [];
$arguments = $this->parseAuthorizationArguments($this->authorization['arguments'] ?? []);
$type = $this->authorization['type'] ?? null;
return match ($type) {
'all' => Gate::check($abilities, $arguments),
'any' => Gate::any($abilities, $arguments),
default => false,
};
}
public function getAuthorizationResponse(): Response
{
if ($this->authorization === null) {
return $this->getHasActionsLivewire()->getDefaultActionAuthorizationResponse($this) ?? Response::allow();
}
if (! is_array($this->authorization)) {
$response = $this->evaluate($this->authorization);
return match (true) {
$response instanceof Response => $response,
(bool) $response => Response::allow(),
default => Response::deny(),
};
}
$abilities = $this->authorization['abilities'] ?? [];
$arguments = $this->parseAuthorizationArguments($this->authorization['arguments'] ?? []);
$type = $this->authorization['type'] ?? null;
foreach ($abilities as $ability) {
$response = Gate::inspect($ability, Arr::wrap($arguments));
if (($type === 'any') && $response->allowed()) {
return $response;
}
if (($type === 'all') && $response->denied()) {
return $response;
}
}
return Response::allow();
}
public function getAuthorizationResponseWithMessage(): Response
{
$response = $this->getAuthorizationResponse();
if ($response->allowed()) {
return $response;
}
$message = $this->getAuthorizationMessage();
if (filled($message)) {
invade($response)->message = $message; /** @phpstan-ignore-line */
return $response;
}
if (blank($response->message())) {
throw new LogicException('An authorization was denied without a message.');
}
return $response;
}
public function authorizationMessage(string | Closure | null $message): static
{
$this->authorizationMessage = $message;
return $this;
}
public function getAuthorizationMessage(): ?string
{
return $this->evaluate($this->authorizationMessage);
}
public function authorizationTooltip(bool | Closure $condition = true): static
{
$this->hasAuthorizationTooltip = $condition;
return $this;
}
public function authorizationNotification(bool | Closure $condition = true): static
{
$this->hasAuthorizationNotification = $condition;
return $this;
}
public function hasAuthorizationTooltip(): bool
{
return (bool) $this->evaluate($this->hasAuthorizationTooltip);
}
public function hasAuthorizationNotification(): bool
{
return (bool) $this->evaluate($this->hasAuthorizationNotification);
}
public function isAuthorizedOrNotHiddenWhenUnauthorized(): bool
{
if ($this->hasAuthorizationTooltip()) {
return true;
}
if ($this->hasAuthorizationNotification()) {
return true;
}
return $this->isAuthorized();
}
public function authorizeIndividualRecords(bool | string | Closure | null $callback = true): static
{
$this->authorizeIndividualRecords = $callback;
return $this;
}
public function getIndividualRecordAuthorizationResponse(Model $record): Response
{
if (is_string($this->authorizeIndividualRecords)) {
return Gate::inspect($this->authorizeIndividualRecords, Arr::wrap($record));
}
$resolver = ($this->authorizeIndividualRecords instanceof Closure)
? $this->authorizeIndividualRecords
: $this->getHasActionsLivewire()->getDefaultActionIndividualRecordAuthorizationResponseResolver($this);
if (! $resolver) {
throw new LogicException('No function was passed to [authorizeIndividualRecords()].');
}
$response = $resolver($record);
if ($response instanceof Response) {
return $response;
}
return $response ? Response::allow() : Response::deny();
}
public function shouldAuthorizeIndividualRecords(): bool
{
return filled($this->authorizeIndividualRecords) && ($this->authorizeIndividualRecords !== false);
}
}
@@ -0,0 +1,30 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
trait CanBeBooted
{
protected ?Closure $bootUsing = null;
protected bool $isBooted = false;
public function bootUsing(?Closure $callback): static
{
$this->bootUsing = $callback;
return $this;
}
public function boot(): void
{
if ($this->isBooted) {
return;
}
$this->isBooted = true;
$this->evaluate($this->bootUsing);
}
}
@@ -0,0 +1,39 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
trait CanBeDisabled
{
protected bool | Closure $isDisabled = false;
public function disabled(bool | Closure $condition = true): static
{
$this->isDisabled = $condition;
return $this;
}
public function isDisabled(): bool
{
if ($this->evaluate($this->isDisabled)) {
return true;
}
if ($this->isHidden()) {
return true;
}
if ($this->hasAuthorizationNotification()) {
return false;
}
return ! $this->isAuthorized();
}
public function isEnabled(): bool
{
return ! $this->isDisabled();
}
}
@@ -0,0 +1,58 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
use Filament\Actions\ActionGroup;
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;
}
public function visible(bool | Closure $condition = true): static
{
$this->isVisible = $condition;
return $this;
}
public function isHidden(): bool
{
if ($this->getGroup()?->baseIsHidden()) {
return true;
}
return $this->isHiddenInGroup();
}
public function isHiddenInGroup(): bool
{
if ($this->evaluate($this->isHidden)) {
return true;
}
if (! $this->evaluate($this->isVisible)) {
return true;
}
if ($this instanceof ActionGroup) {
return false;
}
return ! $this->isAuthorizedOrNotHiddenWhenUnauthorized();
}
public function isVisible(): bool
{
return ! $this->isHidden();
}
}
@@ -0,0 +1,22 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
trait CanBeLabeledFrom
{
protected string | Closure | null $labeledFrom = null;
public function labeledFrom(string | Closure | null $breakpoint = null): static
{
$this->labeledFrom = $breakpoint;
return $this;
}
public function getLabeledFromBreakpoint(): ?string
{
return $this->evaluate($this->labeledFrom);
}
}
@@ -0,0 +1,50 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
use Filament\Actions\Action;
use Filament\Schemas\Schema;
trait CanBeMounted
{
protected ?Closure $mountUsing = null;
/**
* @param array<string, mixed> $parameters
*/
public function mount(array $parameters): mixed
{
return $this->evaluate($this->getMountUsing(), $parameters);
}
public function mountUsing(?Closure $callback): static
{
$this->mountUsing = $callback;
return $this;
}
/**
* @param array<string, mixed> | Closure | null $data
*/
public function fillForm(array | Closure | null $data): static
{
$this->mountUsing(static function (Action $action, ?Schema $schema) use ($data): void {
$schema?->fill($action->evaluate($data));
});
return $this;
}
public function getMountUsing(): Closure
{
return $this->mountUsing ?? static function (?Schema $schema = null): void {
if (! $schema) {
return;
}
$schema->fill();
};
}
}
@@ -0,0 +1,22 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
trait CanBeOutlined
{
protected bool | Closure $isOutlined = false;
public function outlined(bool | Closure $condition = true): static
{
$this->isOutlined = $condition;
return $this;
}
public function isOutlined(): bool
{
return (bool) $this->evaluate($this->isOutlined);
}
}
@@ -0,0 +1,22 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
trait CanBeRateLimited
{
protected int | Closure | null $rateLimit = null;
public function rateLimit(int | Closure | null $maxAttempts): static
{
$this->rateLimit = $maxAttempts;
return $this;
}
public function getRateLimit(): ?int
{
return $this->evaluate($this->rateLimit);
}
}
@@ -0,0 +1,22 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
trait CanBeSorted
{
protected int | Closure | null $sort = null;
public function sort(int | Closure | null $sort): static
{
$this->sort = $sort;
return $this;
}
public function getSort(): int
{
return $this->evaluate($this->sort) ?? 0;
}
}
@@ -0,0 +1,20 @@
<?php
namespace Filament\Actions\Concerns;
trait CanCallParentAction
{
protected ?string $parentActionCallLivewireClickHandler = null;
public function callParent(?string $handler): static
{
$this->parentActionCallLivewireClickHandler = $handler;
return $this;
}
public function getParentActionCallLivewireClickHandler(): ?string
{
return $this->parentActionCallLivewireClickHandler;
}
}
@@ -0,0 +1,22 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
trait CanClose
{
protected bool | Closure $shouldClose = false;
public function close(bool | Closure $condition = true): static
{
$this->shouldClose = $condition;
return $this;
}
public function shouldClose(): bool
{
return (bool) $this->evaluate($this->shouldClose);
}
}
@@ -0,0 +1,25 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
trait CanCustomizeProcess
{
protected ?Closure $using = null;
public function using(?Closure $using): static
{
$this->using = $using;
return $this;
}
/**
* @param array<string, mixed> $parameters
*/
public function process(?Closure $default, array $parameters = []): mixed
{
return $this->evaluate($this->using ?? $default, $parameters);
}
}
@@ -0,0 +1,22 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
trait CanDeselectRecordsAfterCompletion
{
protected bool | Closure $shouldDeselectRecordsAfterCompletion = false;
public function deselectRecordsAfterCompletion(bool | Closure $condition = true): static
{
$this->shouldDeselectRecordsAfterCompletion = $condition;
return $this;
}
public function shouldDeselectRecordsAfterCompletion(): bool
{
return (bool) $this->evaluate($this->shouldDeselectRecordsAfterCompletion);
}
}
@@ -0,0 +1,137 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
trait CanDispatchEvent
{
protected string | Closure | null $event = null;
/**
* @var array<mixed> | Closure
*/
protected array | Closure $eventData = [];
protected string | bool $dispatchDirection = false;
protected ?string $dispatchToComponent = null;
/**
* @param array<mixed> | Closure $data
*/
public function dispatch(
string | Closure | null $event,
array | Closure $data = [],
): static {
$this->event = $event;
$this->eventData($data);
$this->dispatchDirection = false;
return $this;
}
/**
* @param array<int, mixed> | Closure $data
*/
public function dispatchSelf(
string | Closure | null $event,
array | Closure $data = [],
): static {
$this->dispatch($event, $data);
$this->dispatchDirection = 'self';
return $this;
}
/**
* @param array<int, mixed> | Closure $data
*/
public function dispatchTo(
string $component,
string | Closure | null $event,
array | Closure $data = [],
): static {
$this->dispatch($event, $data);
$this->dispatchDirection = 'to';
$this->dispatchToComponent = $component;
return $this;
}
/**
* @deprecated Use `dispatch()` instead.
*
* @param array<int, mixed> | Closure $data
*/
public function emit(
string | Closure | null $event,
array | Closure $data = [],
): static {
$this->dispatch($event, $data);
return $this;
}
/**
* @deprecated Use `dispatchSelf()` instead.
*
* @param array<int, mixed> | Closure $data
*/
public function emitSelf(
string | Closure | null $event,
array | Closure $data = [],
): static {
$this->dispatchSelf($event, $data);
return $this;
}
/**
* @deprecated Use `dispatchTo()` instead.
*
* @param array<int, mixed> | Closure $data
*/
public function emitTo(
string $component,
string | Closure | null $event,
array | Closure $data = [],
): static {
$this->dispatchTo($component, $event, $data);
return $this;
}
public function getEvent(): ?string
{
return $this->evaluate($this->event);
}
/**
* @param array<mixed> | Closure $data
*/
public function eventData(array | Closure $data): static
{
$this->eventData = $data;
return $this;
}
/**
* @return array<mixed>
*/
public function getEventData(): array
{
return $this->evaluate($this->eventData);
}
public function getDispatchDirection(): string | bool
{
return $this->dispatchDirection;
}
public function getDispatchToComponent(): ?string
{
return $this->dispatchToComponent;
}
}
@@ -0,0 +1,551 @@
<?php
namespace Filament\Actions\Concerns;
use AnourValar\EloquentSerialize\Facades\EloquentSerializeFacade;
use Closure;
use Filament\Actions\ExportAction;
use Filament\Actions\ExportBulkAction;
use Filament\Actions\Exports\Enums\Contracts\ExportFormat as ExportFormatInterface;
use Filament\Actions\Exports\Enums\ExportFormat;
use Filament\Actions\Exports\ExportColumn;
use Filament\Actions\Exports\Exporter;
use Filament\Actions\Exports\Jobs\CreateXlsxFile;
use Filament\Actions\Exports\Jobs\ExportCompletion;
use Filament\Actions\Exports\Jobs\PrepareCsvExport;
use Filament\Actions\Exports\Models\Export;
use Filament\Actions\View\ActionsIconAlias;
use Filament\Facades\Filament;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Fieldset;
use Filament\Schemas\Components\Flex;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Support\Enums\Width;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Contracts\HasTable;
use Illuminate\Bus\PendingBatch;
use Illuminate\Foundation\Bus\PendingChain;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Number;
use Illuminate\Support\Str;
use Livewire\Component;
use LogicException;
trait CanExportRecords
{
/**
* @var class-string<Exporter>
*/
protected string $exporter;
protected ?string $job = null;
protected int | Closure $chunkSize = 100;
protected int | Closure $columnMappingColumns = 1;
protected int | Closure | null $maxRows = null;
protected string | Closure | null $csvDelimiter = null;
/**
* @var array<string, mixed> | Closure
*/
protected array | Closure $options = [];
protected string | Closure | null $fileDisk = null;
protected string | Closure | null $fileName = null;
/**
* @var array<ExportFormatInterface> | Closure | null
*/
protected array | Closure | null $formats = null;
protected ?Closure $modifyQueryUsing = null;
protected bool | Closure $hasColumnMapping = true;
protected bool | Closure $isEnablingVisibleTableColumnsByDefault = false;
protected string | Closure | null $authGuard = null;
protected function setUp(): void
{
parent::setUp();
$this->label(fn (ExportAction | ExportBulkAction $action): string => __('filament-actions::export.label', ['label' => $action->getPluralModelLabel()]));
$this->modalHeading(fn (ExportAction | ExportBulkAction $action): string => __('filament-actions::export.modal.heading', ['label' => $action->getTitleCasePluralModelLabel()]));
$this->modalSubmitActionLabel(__('filament-actions::export.modal.actions.export.label'));
$this->groupedIcon(FilamentIcon::resolve(ActionsIconAlias::EXPORT_ACTION_GROUPED) ?? Heroicon::ArrowDownTray);
$this->schema(fn (ExportAction | ExportBulkAction $action): array => [
...($action->hasColumnMapping() ? [Fieldset::make(__('filament-actions::export.modal.form.columns.label'))
->columns(match ($columns = $action->getColumnMappingColumns()) {
1 => 1,
2 => [
'sm' => 2,
'lg' => 2,
],
3 => [
'sm' => 2,
'lg' => 3,
],
default => [
'sm' => 2,
'md' => 3,
'lg' => $columns,
],
})
->schema(function () use ($action): array {
$isEnablingVisibleTableColumnsByDefault = $action->isEnablingVisibleTableColumnsByDefault();
$visibleTableColumnNames = $isEnablingVisibleTableColumnsByDefault ? $action->getVisibleTableColumnNames() : [];
return array_map(
fn (ExportColumn $column): Flex => Flex::make([
Forms\Components\Checkbox::make('isEnabled')
->label(__('filament-actions::export.modal.form.columns.form.is_enabled.label', ['column' => $column->getName()]))
->hiddenLabel()
->default(
$isEnablingVisibleTableColumnsByDefault
? (in_array($column->getName(), $visibleTableColumnNames) && $column->isEnabledByDefault())
: $column->isEnabledByDefault()
)
->live()
->grow(false),
Forms\Components\TextInput::make('label')
->label(__('filament-actions::export.modal.form.columns.form.label.label', ['column' => $column->getName()]))
->hiddenLabel()
->default($column->getLabel())
->placeholder($column->getLabel())
->disabled(fn (Get $get): bool => ! $get('isEnabled'))
->required(fn (Get $get): bool => (bool) $get('isEnabled')),
])
->verticallyAlignCenter()
->statePath($column->getName()),
$action->getExporter()::getColumns(),
);
})
->statePath('columnMap')] : []),
...$action->getExporter()::getOptionsFormComponents(),
]);
$this->action(function (ExportAction | ExportBulkAction $action, array $data, Component $livewire): void {
$exporter = $action->getExporter();
if ($livewire instanceof HasTable) {
$query = $livewire->getTableQueryForExport();
} else {
$query = $exporter::getModel()::query();
}
$query = $exporter::modifyQuery($query);
$options = array_merge(
$action->getOptions(),
Arr::except($data, ['columnMap']),
);
if ($this->modifyQueryUsing) {
$query = $this->evaluate($this->modifyQueryUsing, [
'query' => $query,
'options' => $options,
]) ?? $query;
}
$records = $action instanceof ExportBulkAction ? $action->getIndividuallyAuthorizedSelectedRecords() : null;
$totalRows = $records ? $records->count() : $query->toBase()->getCountForPagination();
if ((! $records) && $query->getQuery()->limit) {
$totalRows = min($totalRows, $query->getQuery()->limit);
}
$maxRows = $action->getMaxRows() ?? $totalRows;
if ($maxRows < $totalRows) {
$action->failureNotification(
Notification::make()
->title(__('filament-actions::export.notifications.max_rows.title'))
->body(trans_choice('filament-actions::export.notifications.max_rows.body', $maxRows, [
'count' => Number::format($maxRows),
]))
->danger(),
);
$action->failure();
return;
}
$authGuard = $action->getAuthGuard();
$user = auth($authGuard)->user();
if ($action->hasColumnMapping()) {
$columnMap = collect($data['columnMap'])
->dot()
->reduce(fn (Collection $carry, mixed $value, string $key): Collection => $carry->mergeRecursive([
Str::beforeLast($key, '.') => [Str::afterLast($key, '.') => $value],
]), collect())
->filter(fn (array $column): bool => $column['isEnabled'] ?? false)
->mapWithKeys(fn (array $column, string $columnName): array => [$columnName => $column['label']])
->all();
} else {
$isEnablingVisibleTableColumnsByDefault = $action->isEnablingVisibleTableColumnsByDefault();
$visibleTableColumnNames = $isEnablingVisibleTableColumnsByDefault ? $action->getVisibleTableColumnNames() : [];
$columnMap = collect($exporter::getColumns())
->when(
$isEnablingVisibleTableColumnsByDefault,
fn ($columns): Collection => $columns->filter(
fn (ExportColumn $column): bool => in_array($column->getName(), $visibleTableColumnNames) && $column->isEnabledByDefault(),
),
)
->mapWithKeys(fn (ExportColumn $column): array => [$column->getName() => $column->getLabel()])
->all();
}
$export = app(Export::class);
$export->user()->associate($user);
$export->exporter = $exporter;
$export->total_rows = $totalRows;
$exporter = $export->getExporter(
columnMap: $columnMap,
options: $options,
);
$export->file_disk = $action->getFileDisk() ?? $exporter->getFileDisk();
// Temporary save to obtain the sequence number of the export file.
$export->save();
// Delete the export directory to prevent data contamination from previous exports with the same ID.
$export->deleteFileDirectory();
$export->file_name = $action->getFileName($export) ?? $exporter->getFileName($export);
$export->save();
$formats = $action->getFormats() ?? $exporter->getFormats();
$hasCsv = in_array(ExportFormat::Csv, $formats);
$hasXlsx = in_array(ExportFormat::Xlsx, $formats);
$serializedQuery = EloquentSerializeFacade::serialize($query);
$job = $action->getJob();
$jobQueue = $exporter->getJobQueue();
$jobConnection = $exporter->getJobConnection();
$jobBatchName = $exporter->getJobBatchName();
// We do not want to send the loaded user relationship to the queue in job payloads,
// in case it contains attributes that are not serializable, such as binary columns.
$export->unsetRelation('user');
$makeCreateXlsxFileJob = fn (): CreateXlsxFile => app(CreateXlsxFile::class, [
'export' => $export,
'columnMap' => $columnMap,
'options' => $options,
]);
Bus::chain([
Bus::batch([app($job, [
'export' => $export,
'query' => $serializedQuery,
'columnMap' => $columnMap,
'options' => $options,
'chunkSize' => $action->getChunkSize(),
'records' => $records?->all(),
])])
->allowFailures()
->when(
filled($jobQueue),
fn (PendingBatch $batch) => $batch->onQueue($jobQueue),
)
->when(
filled($jobConnection),
fn (PendingBatch $batch) => $batch->onConnection($jobConnection),
)
->when(
filled($jobBatchName),
fn (PendingBatch $batch) => $batch->name($jobBatchName),
),
...(($hasXlsx && (! $hasCsv)) ? [$makeCreateXlsxFileJob()] : []),
app(ExportCompletion::class, [
'authGuard' => $authGuard,
'export' => $export,
'columnMap' => $columnMap,
'formats' => $formats,
'options' => $options,
]),
...(($hasXlsx && $hasCsv) ? [$makeCreateXlsxFileJob()] : []),
])
->when(
filled($jobQueue),
fn (PendingChain $chain) => $chain->onQueue($jobQueue),
)
->when(
filled($jobConnection),
fn (PendingChain $chain) => $chain->onConnection($jobConnection),
)
->dispatch();
if (
($jobConnection === 'sync')
|| (blank($jobConnection) && (config('queue.default') === 'sync'))
) {
$action->successNotification(null);
$action->successNotificationTitle(null);
return;
}
$action->successNotification(
Notification::make()
->title($action->getSuccessNotificationTitle())
->body(trans_choice('filament-actions::export.notifications.started.body', $export->total_rows, [
'count' => Number::format($export->total_rows),
]))
->success(),
);
});
$this->defaultColor('gray');
$this->modalWidth(static fn (ExportAction | ExportBulkAction $action): Width => match ($action->getColumnMappingColumns()) {
1 => Width::Medium,
2 => Width::ThreeExtraLarge,
3 => Width::FiveExtraLarge,
default => Width::SevenExtraLarge,
});
$this->successNotificationTitle(__('filament-actions::export.notifications.started.title'));
if (! $this instanceof ExportBulkAction) {
$this->model(fn (ExportAction $action): string => $action->getExporter()::getModel());
}
}
public static function getDefaultName(): ?string
{
return 'export';
}
public function columnMappingColumns(int | Closure $columns): static
{
$this->columnMappingColumns = $columns;
return $this;
}
public function getColumnMappingColumns(): int
{
return $this->evaluate($this->columnMappingColumns);
}
/**
* @param class-string<Exporter> $exporter
*/
public function exporter(string $exporter): static
{
$this->exporter = $exporter;
return $this;
}
/**
* @return array<string>
*/
public function getVisibleTableColumnNames(): array
{
if (! $this->getLivewire() instanceof HasTable) {
throw new LogicException('Cannot get visible table columns from a non-table Livewire component.');
}
return array_keys($this->getLivewire()->getTable()->getVisibleColumns());
}
/**
* @param class-string | null $job
*/
public function job(?string $job): static
{
$this->job = $job;
return $this;
}
public function chunkSize(int | Closure $size): static
{
$this->chunkSize = $size;
return $this;
}
public function maxRows(int | Closure | null $rows): static
{
$this->maxRows = $rows;
return $this;
}
public function csvDelimiter(string | Closure | null $delimiter): static
{
$this->csvDelimiter = $delimiter;
return $this;
}
/**
* @return class-string<Exporter>
*/
public function getExporter(): string
{
return $this->exporter;
}
/**
* @return class-string
*/
public function getJob(): string
{
return $this->job ?? PrepareCsvExport::class;
}
public function getChunkSize(): int
{
return $this->evaluate($this->chunkSize);
}
public function getMaxRows(): ?int
{
return $this->evaluate($this->maxRows);
}
/**
* @param array<string, mixed> | Closure $options
*/
public function options(array | Closure $options): static
{
$this->options = $options;
return $this;
}
/**
* @return array<string, mixed>
*/
public function getOptions(): array
{
return $this->evaluate($this->options);
}
public function fileDisk(string | Closure | null $disk): static
{
$this->fileDisk = $disk;
return $this;
}
public function getFileDisk(): ?string
{
return $this->evaluate($this->fileDisk);
}
public function fileName(string | Closure | null $name): static
{
$this->fileName = $name;
return $this;
}
public function getFileName(Export $export): ?string
{
return $this->evaluate($this->fileName, [
'export' => $export,
]);
}
/**
* @param array<ExportFormatInterface> | Closure | null $formats
*/
public function formats(array | Closure | null $formats): static
{
$this->formats = $formats;
return $this;
}
/**
* @return array<ExportFormatInterface> | null
*/
public function getFormats(): ?array
{
return $this->evaluate($this->formats);
}
public function modifyQueryUsing(?Closure $callback): static
{
$this->modifyQueryUsing = $callback;
return $this;
}
public function columnMapping(bool | Closure $condition = true): static
{
$this->hasColumnMapping = $condition;
return $this;
}
public function hasColumnMapping(): bool
{
return (bool) $this->evaluate($this->hasColumnMapping);
}
public function enableVisibleTableColumnsByDefault(bool | Closure $condition = true): static
{
$this->isEnablingVisibleTableColumnsByDefault = $condition;
return $this;
}
public function isEnablingVisibleTableColumnsByDefault(): bool
{
return (bool) $this->evaluate($this->isEnablingVisibleTableColumnsByDefault);
}
public function authGuard(string | Closure | null $authGuard): static
{
$this->authGuard = $authGuard;
return $this;
}
public function getAuthGuard(): string
{
$guard = $this->evaluate($this->authGuard);
if (filled($guard)) {
return $guard;
}
if (class_exists(Filament::class) && Filament::isServing()) {
return Filament::getAuthGuard();
}
$authGuard = auth();
if (! property_exists($authGuard, 'name')) {
return config('auth.defaults.guard') ?? 'web';
}
return $authGuard->name;
}
}
@@ -0,0 +1,36 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
trait CanFetchSelectedRecords
{
protected bool | Closure $shouldFetchSelectedRecords = true;
protected int | Closure | null $selectedRecordsChunkSize = null;
public function fetchSelectedRecords(bool | Closure $condition = true): static
{
$this->shouldFetchSelectedRecords = $condition;
return $this;
}
public function shouldFetchSelectedRecords(): bool
{
return (bool) $this->evaluate($this->shouldFetchSelectedRecords);
}
public function chunkSelectedRecords(int | Closure | null $chunkSize = 100): static
{
$this->selectedRecordsChunkSize = $chunkSize;
return $this;
}
public function getSelectedRecordsChunkSize(): ?int
{
return $this->evaluate($this->selectedRecordsChunkSize);
}
}
@@ -0,0 +1,291 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;
use Filament\Notifications\Notification;
use Illuminate\Auth\Access\Response;
trait CanNotify
{
protected Notification | Closure | null $failureNotification = null;
protected Notification | Closure | null $successNotification = null;
protected Notification | Closure | null $unauthorizedNotification = null;
protected Notification | Closure | null $rateLimitedNotification = null;
protected string | Closure | null $failureNotificationTitle = null;
protected string | Closure | null $successNotificationTitle = null;
protected string | Closure | null $unauthorizedNotificationTitle = null;
protected string | Closure | null $rateLimitedNotificationTitle = null;
protected string | Closure | null $failureNotificationBody = null;
protected string | Closure | null $missingBulkAuthorizationFailureNotificationMessage = null;
protected string | Closure | null $missingBulkProcessingFailureNotificationMessage = null;
public function missingBulkAuthorizationFailureNotificationMessage(string | Closure | null $message): static
{
$this->missingBulkAuthorizationFailureNotificationMessage = $message;
return $this;
}
public function missingBulkProcessingFailureNotificationMessage(string | Closure | null $message): static
{
$this->missingBulkProcessingFailureNotificationMessage = $message;
return $this;
}
/**
* @return array<string, mixed>
*/
protected function getFailureNotificationNamedInjections(): array
{
$processingFailureMessages = $this->getBulkProcessingFailureMessages();
return [
'authorizationFailureMessages' => $this->bulkAuthorizationFailureMessages,
'failureCount' => $this->totalSelectedRecordsCount - $this->successfulSelectedRecordsCount,
'failureMessages' => [...$this->bulkAuthorizationFailureMessages, ...$processingFailureMessages],
'isAll' => ! $this->successfulSelectedRecordsCount,
'missingAuthorizationFailureMessageCount' => $this->bulkAuthorizationFailureWithoutMessageCount,
'missingProcessingFailureMessageCount' => $this->bulkProcessingFailureWithoutMessageCount,
'processingFailureMessages' => $processingFailureMessages,
'successCount' => $this->successfulSelectedRecordsCount,
'totalCount' => $this->totalSelectedRecordsCount,
];
}
public function sendFailureNotification(): static
{
$notification = $this->evaluate($this->failureNotification, [
...$this->getFailureNotificationNamedInjections(),
'notification' => $notification = Notification::make()
->when(
$this->successfulSelectedRecordsCount,
fn (Notification $notification) => $notification->warning(),
fn (Notification $notification) => $notification->danger(),
)
->title($this->getFailureNotificationTitle())
->body($this->getFailureNotificationBody())
->persistent(),
]) ?? $notification;
if (filled($notification?->getTitle())) {
$notification->send();
}
return $this;
}
public function failureNotification(Notification | Closure | null $notification): static
{
$this->failureNotification = $notification;
return $this;
}
/**
* @deprecated Use `failureNotificationTitle()` instead.
*/
public function failureNotificationMessage(string | Closure | null $message): static
{
return $this->failureNotificationTitle($message);
}
public function failureNotificationTitle(string | Closure | null $title): static
{
$this->failureNotificationTitle = $title;
return $this;
}
public function failureNotificationBody(string | Closure | null $body): static
{
$this->failureNotificationBody = $body;
return $this;
}
public function sendSuccessNotification(): static
{
$notification = $this->evaluate($this->successNotification, [
'notification' => $notification = Notification::make()
->success()
->title($this->getSuccessNotificationTitle()),
]) ?? $notification;
if (filled($notification?->getTitle())) {
$notification->send();
}
return $this;
}
public function successNotification(Notification | Closure | null $notification): static
{
$this->successNotification = $notification;
return $this;
}
/**
* @deprecated Use `successNotificationTitle()` instead.
*/
public function successNotificationMessage(string | Closure | null $message): static
{
return $this->successNotificationTitle($message);
}
public function successNotificationTitle(string | Closure | null $title): static
{
$this->successNotificationTitle = $title;
return $this;
}
public function sendUnauthorizedNotification(Response $response): static
{
$notification = $this->evaluate($this->unauthorizedNotification, [
'notification' => $notification = Notification::make()
->danger()
->title($this->getUnauthorizedNotificationTitle($response) ?? $response->message())
->persistent(),
'response' => $response,
]) ?? $notification;
if (filled($notification?->getTitle())) {
$notification->send();
}
return $this;
}
public function unauthorizedNotification(Notification | Closure | null $notification): static
{
$this->unauthorizedNotification = $notification;
return $this;
}
public function unauthorizedNotificationTitle(string | Closure | null $title): static
{
$this->unauthorizedNotificationTitle = $title;
return $this;
}
public function sendRateLimitedNotification(TooManyRequestsException $exception): static
{
$notification = $this->evaluate($this->rateLimitedNotification, [
'exception' => $exception,
'minutes' => $exception->minutesUntilAvailable,
'notification' => $notification = Notification::make()
->danger()
->title($this->getRateLimitedNotificationTitle($exception) ?? __('filament-actions::notifications.throttled.title', [
'seconds' => $exception->secondsUntilAvailable,
'minutes' => $exception->minutesUntilAvailable,
]))
->body(__('filament-actions::notifications.throttled.body', [
'seconds' => $exception->secondsUntilAvailable,
'minutes' => $exception->minutesUntilAvailable,
])),
'seconds' => $exception->secondsUntilAvailable,
]) ?? $notification;
$notification->send();
return $this;
}
public function rateLimitedNotification(Notification | Closure | null $notification): static
{
$this->rateLimitedNotification = $notification;
return $this;
}
public function rateLimitedNotificationTitle(string | Closure | null $title): static
{
$this->rateLimitedNotificationTitle = $title;
return $this;
}
public function getSuccessNotificationTitle(): ?string
{
return $this->evaluate($this->successNotificationTitle);
}
public function getFailureNotificationTitle(): ?string
{
return $this->evaluate($this->failureNotificationTitle, $this->getFailureNotificationNamedInjections());
}
public function getFailureNotificationBody(): ?string
{
$body = $this->evaluate($this->failureNotificationBody, $this->getFailureNotificationNamedInjections());
if (filled($body)) {
return $body;
}
$messages = [
...$this->bulkAuthorizationFailureMessages,
...($this->bulkAuthorizationFailureWithoutMessageCount && filled($message = $this->evaluate(
$this->missingBulkAuthorizationFailureNotificationMessage,
[
'count' => $this->bulkAuthorizationFailureWithoutMessageCount,
'failureCount' => $this->bulkAuthorizationFailureWithoutMessageCount,
'isAll' => $this->bulkAuthorizationFailureWithoutMessageCount === $this->totalSelectedRecordsCount,
'total' => $this->totalSelectedRecordsCount,
'totalCount' => $this->totalSelectedRecordsCount,
],
)) ? [$message] : []),
...$this->getBulkProcessingFailureMessages(),
...($this->bulkProcessingFailureWithoutMessageCount && filled($message = $this->evaluate(
$this->missingBulkProcessingFailureNotificationMessage,
[
'count' => $this->bulkProcessingFailureWithoutMessageCount,
'failureCount' => $this->bulkProcessingFailureWithoutMessageCount,
'isAll' => $this->bulkProcessingFailureWithoutMessageCount === $this->totalSelectedRecordsCount,
'total' => $this->totalSelectedRecordsCount,
'totalCount' => $this->totalSelectedRecordsCount,
],
)) ? [$message] : []),
];
return implode(
'',
array_map(
fn (string $message): string => "<p>{$message}</p>",
$messages,
),
);
}
public function getUnauthorizedNotificationTitle(Response $response): ?string
{
return $this->evaluate($this->unauthorizedNotificationTitle, [
'response' => $response,
]);
}
public function getRateLimitedNotificationTitle(TooManyRequestsException $exception): ?string
{
return $this->evaluate($this->rateLimitedNotificationTitle, [
'exception' => $exception,
'minutes' => $exception->minutesUntilAvailable,
'seconds' => $exception->secondsUntilAvailable,
]);
}
}
@@ -0,0 +1,719 @@
<?php
namespace Filament\Actions\Concerns;
use BackedEnum;
use Closure;
use Filament\Actions\Action;
use Filament\Actions\View\ActionsIconAlias;
use Filament\Support\Enums\Alignment;
use Filament\Support\Enums\Width;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
use Filament\Support\View\Components\ModalComponent;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Arr;
trait CanOpenModal
{
/**
* @var array<string, Action>
*/
protected array $cachedExtraModalFooterActions;
/**
* @var array<Action> | Closure
*/
protected array | Closure $extraModalFooterActions = [];
protected bool | Closure | null $isModalFooterSticky = null;
protected bool | Closure | null $isModalHeaderSticky = null;
/**
* @var array<string, Action>
*/
protected array $cachedModalActions;
/**
* @var array<Action | Closure>
*/
protected array $modalActions = [];
protected bool | Closure $isModalSlideOver = false;
protected Alignment | string | Closure | null $modalAlignment = null;
/**
* @var array<string, Action>
*/
protected array $cachedModalFooterActions;
/**
* @var array<Action> | Closure | null
*/
protected array | Closure | null $modalFooterActions = null;
protected Alignment | string | Closure | null $modalFooterActionsAlignment = null;
protected Action | bool | Closure | null $modalCancelAction = null;
protected string | Closure | null $modalCancelActionLabel = null;
protected Action | bool | Closure | null $modalSubmitAction = null;
protected string | Closure | null $modalSubmitActionLabel = null;
protected View | Htmlable | Closure | null $modalContent = null;
protected View | Htmlable | Closure | null $modalContentFooter = null;
protected string | Htmlable | Closure | null $modalHeading = null;
protected string | Htmlable | Closure | null $modalDescription = null;
protected Width | string | Closure | null $modalWidth = null;
protected bool | Closure | null $hasModal = null;
protected bool | Closure | null $isModalHidden = null;
protected bool | Closure | null $hasModalCloseButton = null;
protected bool | Closure | null $isModalClosedByClickingAway = null;
protected bool | Closure | null $isModalClosedByEscaping = null;
protected bool | Closure | null $isModalAutofocused = null;
protected string | BackedEnum | Htmlable | Closure | null $modalIcon = null;
/**
* @var string | array<string> | Closure | null
*/
protected string | array | Closure | null $modalIconColor = null;
public function closeModalByClickingAway(bool | Closure | null $condition = true): static
{
$this->isModalClosedByClickingAway = $condition;
return $this;
}
public function closeModalByEscaping(bool | Closure | null $condition = true): static
{
$this->isModalClosedByEscaping = $condition;
return $this;
}
/**
* @deprecated Use `modalAlignment(Alignment::Center)` instead.
*/
public function centerModal(bool | Closure | null $condition = true): static
{
if ($this->evaluate($condition)) {
$this->modalAlignment(Alignment::Center);
}
return $this;
}
public function modalAlignment(Alignment | string | Closure | null $alignment = null): static
{
$this->modalAlignment = $alignment;
return $this;
}
public function modalCloseButton(bool | Closure | null $condition = true): static
{
$this->hasModalCloseButton = $condition;
return $this;
}
public function modalAutofocus(bool | Closure | null $condition = true): static
{
$this->isModalAutofocused = $condition;
return $this;
}
public function modalIcon(string | BackedEnum | Htmlable | Closure | null $icon = null): static
{
$this->modalIcon = $icon;
return $this;
}
/**
* @param string | array<string> | Closure | null $color
*/
public function modalIconColor(string | array | Closure | null $color = null): static
{
$this->modalIconColor = $color;
return $this;
}
public function slideOver(bool | Closure $condition = true): static
{
$this->isModalSlideOver = $condition;
return $this;
}
/**
* @param array<Action> | Closure | null $actions
*
*@deprecated Use `modalFooterActions()` instead.
*/
public function modalActions(array | Closure | null $actions = null): static
{
$this->modalFooterActions($actions);
return $this;
}
/**
* @param array<Action> | Closure | null $actions
*/
public function modalFooterActions(array | Closure | null $actions = null): static
{
$this->modalFooterActions = $actions;
return $this;
}
public function modalFooterActionsAlignment(Alignment | string | Closure | null $alignment = null): static
{
$this->modalFooterActionsAlignment = $alignment;
return $this;
}
/**
* @param array<Action> | Closure $actions
*
*@deprecated Use `extraModalFooterActions()` instead.
*/
public function extraModalActions(array | Closure $actions): static
{
$this->extraModalFooterActions($actions);
return $this;
}
/**
* @param array<Action> | Closure $actions
*/
public function extraModalFooterActions(array | Closure $actions): static
{
$this->extraModalFooterActions = $actions;
return $this;
}
/**
* @param array<Action | Closure> $actions
*/
public function registerModalActions(array $actions): static
{
$this->modalActions = [
...$this->modalActions,
...$actions,
];
return $this;
}
public function modalSubmitAction(Action | bool | Closure | null $action = null): static
{
$this->modalSubmitAction = $action;
return $this;
}
public function modalCancelAction(Action | bool | Closure | null $action = null): static
{
$this->modalCancelAction = $action;
return $this;
}
public function modalSubmitActionLabel(string | Closure | null $label = null): static
{
$this->modalSubmitActionLabel = $label;
return $this;
}
public function modalCancelActionLabel(string | Closure | null $label = null): static
{
$this->modalCancelActionLabel = $label;
return $this;
}
/**
* @deprecated Use `modalSubmitActionLabel()` instead.
*/
public function modalButton(string | Closure | null $label = null): static
{
$this->modalSubmitActionLabel($label);
return $this;
}
public function modalContent(View | Htmlable | Closure | null $content = null): static
{
$this->modalContent = $content;
return $this;
}
/**
* @deprecated Use `modalContentFooter()` instead.
*/
public function modalFooter(View | Htmlable | Closure | null $footer = null): static
{
return $this->modalContentFooter($footer);
}
public function modalContentFooter(View | Htmlable | Closure | null $footer = null): static
{
$this->modalContentFooter = $footer;
return $this;
}
public function modalHeading(string | Htmlable | Closure | null $heading = null): static
{
$this->modalHeading = $heading;
return $this;
}
public function modalDescription(string | Htmlable | Closure | null $description = null): static
{
$this->modalDescription = $description;
return $this;
}
/**
* @deprecated Use `modalDescription()` instead.
*/
public function modalSubheading(string | Htmlable | Closure | null $subheading = null): static
{
$this->modalDescription($subheading);
return $this;
}
public function modalWidth(Width | string | Closure | null $width = null): static
{
$this->modalWidth = $width;
return $this;
}
public function getLivewireCallMountedActionName(): ?string
{
return null;
}
public function modal(bool | Closure | null $condition = true): static
{
$this->hasModal = $condition;
return $this;
}
public function modalHidden(bool | Closure | null $condition = true): static
{
$this->isModalHidden = $condition;
return $this;
}
/**
* @return array<string, Action>
*/
public function getModalFooterActions(): array
{
if ($this->isWizard()) {
return [];
}
if (isset($this->cachedModalFooterActions)) {
return $this->cachedModalFooterActions;
}
if ($this->modalFooterActions !== null) {
$actions = [];
foreach ($this->evaluate($this->modalFooterActions) as $modalAction) {
$actions[$modalAction->getName()] = $this->prepareModalAction($modalAction);
}
return $this->cachedModalFooterActions = $actions;
}
$actions = [];
if ($submitAction = $this->getModalSubmitAction()) {
$actions['submit'] = $submitAction;
}
$actions = [
...$actions,
...$this->getExtraModalFooterActions(),
];
if ($cancelAction = $this->getModalCancelAction()) {
$actions['cancel'] = $cancelAction;
}
if (in_array($this->getModalFooterActionsAlignment(), [Alignment::Center, 'center'])) {
$actions = array_reverse($actions);
}
return $this->cachedModalFooterActions = $actions;
}
public function getModalFooterActionsAlignment(): string | Alignment | null
{
if ($alignment = $this->evaluate($this->modalFooterActionsAlignment)) {
return $alignment;
}
if ($this->isConfirmationRequired()) {
return Alignment::Center;
}
return null;
}
/**
* @return array<string, Action>
*/
public function getModalActions(): array
{
if (isset($this->cachedModalActions)) {
return $this->cachedModalActions;
}
$actions = $this->getModalFooterActions();
foreach ($this->modalActions as $action) {
foreach (Arr::wrap($this->evaluate($action)) as $modalAction) {
$actions[$modalAction->getName()] = $this->prepareModalAction($modalAction);
}
}
return $this->cachedModalActions = $actions;
}
public function getModalAction(string $name): ?Action
{
return $this->getModalActions()[$name] ?? null;
}
public function prepareModalAction(Action $action): Action
{
return $action
->schemaContainer($this->getSchemaContainer())
->schemaComponent($this->getSchemaComponent())
->livewire($this->getLivewire())
->when(
! $action->hasRecord(),
fn (Action $action) => $action->record($this->getRecord()),
)
->table($this->getTable());
}
/**
* @return array<Action>
*/
public function getVisibleModalFooterActions(): array
{
return array_filter(
$this->getModalFooterActions(),
fn (Action $action): bool => $action->isVisible(),
);
}
public function getModalSubmitAction(): ?Action
{
$hasFormWrapper = $this->hasFormWrapper();
$action = static::makeModalAction('submit')
->label($this->getModalSubmitActionLabel())
->submit($hasFormWrapper ? $this->getLivewireCallMountedActionName() : null)
->action($hasFormWrapper ? null : $this->getLivewireCallMountedActionName())
->color(match ($color = $this->getColor()) {
'gray' => 'primary',
default => $color,
});
if ($this->modalSubmitAction !== null) {
$action = $this->evaluate($this->modalSubmitAction, ['action' => $action]) ?? $action;
}
if ($action === false) {
return null;
}
return $action;
}
public function getModalCancelAction(): ?Action
{
$action = static::makeModalAction('cancel')
->label($this->getModalCancelActionLabel())
->close()
->color('gray');
if ($this->modalCancelAction !== null) {
$action = $this->evaluate($this->modalCancelAction, ['action' => $action]) ?? $action;
}
if ($action === false) {
return null;
}
return $action;
}
/**
* @return array<Action>
*/
public function getExtraModalFooterActions(): array
{
if (isset($this->cachedExtraModalFooterActions)) {
return $this->cachedExtraModalFooterActions;
}
$actions = [];
foreach ($this->evaluate($this->extraModalFooterActions) as $action) {
$actions[$action->getName()] = $this->prepareModalAction($action);
}
return $this->cachedExtraModalFooterActions = $actions;
}
public function getModalAlignment(): Alignment | string
{
if ($alignment = $this->evaluate($this->modalAlignment)) {
return $alignment;
}
if ($this->isConfirmationRequired() || in_array($this->getModalWidth(), [Width::ExtraSmall, Width::Small, 'xs', 'sm'])) {
return Alignment::Center;
}
return Alignment::Start;
}
public function getModalSubmitActionLabel(): string
{
if (filled($label = $this->evaluate($this->modalSubmitActionLabel))) {
return $label;
}
if ($this->isConfirmationRequired()) {
return __('filament-actions::modal.actions.confirm.label');
}
return __('filament-actions::modal.actions.submit.label');
}
public function getModalCancelActionLabel(): string
{
return $this->evaluate($this->modalCancelActionLabel) ?? __('filament-actions::modal.actions.cancel.label');
}
public function getModalContent(): View | Htmlable | null
{
return $this->evaluate($this->modalContent);
}
public function getModalContentFooter(): View | Htmlable | null
{
return $this->evaluate($this->modalContentFooter);
}
public function hasModalContent(): bool
{
return $this->modalContent !== null;
}
public function hasModalContentFooter(): bool
{
return $this->modalContentFooter !== null;
}
public function getCustomModalHeading(): string | Htmlable | null
{
return $this->evaluate($this->modalHeading);
}
public function getModalHeading(): string | Htmlable
{
return $this->getCustomModalHeading() ?? $this->getLabel();
}
public function hasCustomModalHeading(): bool
{
return filled($this->getCustomModalHeading());
}
public function getModalDescription(): string | Htmlable | null
{
if (filled($description = $this->evaluate($this->modalDescription))) {
return $description;
}
if ($this->isConfirmationRequired()) {
return __('filament-actions::modal.confirmation');
}
return null;
}
public function hasModalDescription(): bool
{
return filled($this->getModalDescription());
}
public function getModalWidth(): Width | string
{
if ($width = $this->evaluate($this->modalWidth)) {
return $width;
}
if ($this->isConfirmationRequired()) {
return Width::Medium;
}
return Width::FourExtraLarge;
}
public function isModalFooterSticky(): bool
{
return (bool) ($this->evaluate($this->isModalFooterSticky) ?? $this->isModalSlideOver());
}
public function isModalHeaderSticky(): bool
{
return (bool) ($this->evaluate($this->isModalHeaderSticky) ?? $this->isModalSlideOver());
}
public function isModalSlideOver(): bool
{
return (bool) $this->evaluate($this->isModalSlideOver);
}
public function shouldOpenModal(?Closure $checkForSchemaUsing = null): bool
{
if (is_bool($hasModal = $this->evaluate($this->hasModal))) {
return $hasModal;
}
if ($this->evaluate($this->isModalHidden)) {
return false;
}
return $this->hasCustomModalHeading() ||
$this->hasModalDescription() ||
$this->hasModalContent() ||
$this->hasModalContentFooter() ||
(value($checkForSchemaUsing, $this) ?? false);
}
public function hasModalCloseButton(): bool
{
return $this->evaluate($this->hasModalCloseButton) ?? ModalComponent::$hasCloseButton;
}
public function isModalClosedByClickingAway(): bool
{
return (bool) ($this->evaluate($this->isModalClosedByClickingAway) ?? ModalComponent::$isClosedByClickingAway);
}
public function isModalClosedByEscaping(): bool
{
return (bool) ($this->evaluate($this->isModalClosedByEscaping) ?? ModalComponent::$isClosedByEscaping);
}
public function isModalAutofocused(): bool
{
return $this->evaluate($this->isModalAutofocused) ?? ModalComponent::$isAutofocused;
}
/**
* @deprecated Use `makeModalSubmitAction()` instead.
*
* @param array<string, mixed> | null $arguments
*/
public function makeExtraModalAction(string $name, ?array $arguments = null): Action
{
return $this->makeModalSubmitAction($name, $arguments);
}
/**
* @param array<string, mixed> | null $arguments
*/
public function makeModalSubmitAction(string $name, ?array $arguments = null): Action
{
return static::makeModalAction($name)
->callParent($this->getLivewireCallMountedActionName())
->arguments($arguments)
->color('gray');
}
public function makeModalAction(string $name): Action
{
return Action::make($name)
->button();
}
public function getModalIcon(): string | BackedEnum | Htmlable | null
{
if ($icon = $this->evaluate($this->modalIcon)) {
return $icon;
}
if ($this->isConfirmationRequired()) {
return FilamentIcon::resolve(ActionsIconAlias::MODAL_CONFIRMATION) ?? Heroicon::OutlinedExclamationTriangle;
}
return null;
}
/**
* @return string | array<string> | null
*/
public function getModalIconColor(): string | array | null
{
return $this->evaluate($this->modalIconColor) ?? $this->getColor() ?? 'primary';
}
public function stickyModalFooter(bool | Closure $condition = true): static
{
$this->isModalFooterSticky = $condition;
return $this;
}
public function stickyModalHeader(bool | Closure $condition = true): static
{
$this->isModalHeaderSticky = $condition;
return $this;
}
}
@@ -0,0 +1,51 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
trait CanOpenUrl
{
protected bool | Closure $shouldOpenUrlInNewTab = false;
protected string | Closure | null $url = null;
protected bool | Closure $shouldPostToUrl = false;
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 postToUrl(bool | Closure $condition = true): static
{
$this->shouldPostToUrl = $condition;
return $this;
}
public function getUrl(): ?string
{
return $this->evaluate($this->url) ?? $this->getHasActionsLivewire()?->getDefaultActionUrl($this);
}
public function shouldOpenUrlInNewTab(): bool
{
return (bool) $this->evaluate($this->shouldOpenUrlInNewTab);
}
public function shouldPostToUrl(): bool
{
return (bool) $this->evaluate($this->shouldPostToUrl);
}
}
@@ -0,0 +1,56 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
use Filament\Support\Facades\FilamentView;
trait CanRedirect
{
protected string | Closure | null $failureRedirectUrl = null;
protected string | Closure | null $successRedirectUrl = null;
public function dispatchFailureRedirect(): static
{
$url = $this->evaluate($this->failureRedirectUrl) ?? $this->getHasActionsLivewire()->getDefaultActionFailureRedirectUrl($this);
if (filled($url)) {
$this->redirect($url);
}
return $this;
}
public function dispatchSuccessRedirect(): static
{
$url = $this->evaluate($this->successRedirectUrl) ?? $this->getHasActionsLivewire()->getDefaultActionSuccessRedirectUrl($this);
if (filled($url)) {
$this->redirect($url);
}
return $this;
}
public function redirect(string | Closure $url, ?bool $navigate = null): void
{
$url = $this->evaluate($url);
$this->getLivewire()->redirect($url, navigate: $navigate ?? FilamentView::hasSpaMode($url));
}
public function failureRedirectUrl(string | Closure | null $url): static
{
$this->failureRedirectUrl = $url;
return $this;
}
public function successRedirectUrl(string | Closure | null $url): static
{
$this->successRedirectUrl = $url;
return $this;
}
}
@@ -0,0 +1,22 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
trait CanRequireConfirmation
{
protected bool | Closure $isConfirmationRequired = false;
public function requiresConfirmation(bool | Closure $condition = true): static
{
$this->isConfirmationRequired = $condition;
return $this;
}
public function isConfirmationRequired(): bool
{
return $this->isConfirmationRequired = ((bool) $this->evaluate($this->isConfirmationRequired));
}
}
@@ -0,0 +1,42 @@
<?php
namespace Filament\Actions\Concerns;
trait CanSubmitForm
{
protected bool $canSubmitForm = false;
protected ?string $formToSubmit = null;
protected ?string $formId = null;
public function submit(?string $form): static
{
$this->canSubmitForm = filled($form);
$this->formToSubmit = $form;
return $this;
}
public function canSubmitForm(): bool
{
return $this->canSubmitForm;
}
public function getFormToSubmit(): ?string
{
return $this->formToSubmit;
}
public function formId(?string $id): static
{
$this->formId = $id;
return $this;
}
public function getFormId(): ?string
{
return $this->formId;
}
}
@@ -0,0 +1,50 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
use Illuminate\Support\Facades\DB;
trait CanUseDatabaseTransactions
{
protected bool | Closure $hasDatabaseTransactions = false;
public function databaseTransaction(bool | Closure $condition = true): static
{
$this->hasDatabaseTransactions = $condition;
return $this;
}
public function hasDatabaseTransactions(): bool
{
return (bool) $this->evaluate($this->hasDatabaseTransactions);
}
public function beginDatabaseTransaction(): void
{
if (! $this->hasDatabaseTransactions()) {
return;
}
DB::beginTransaction();
}
public function commitDatabaseTransaction(): void
{
if (! $this->hasDatabaseTransactions()) {
return;
}
DB::commit();
}
public function rollBackDatabaseTransaction(): void
{
if (! $this->hasDatabaseTransactions()) {
return;
}
DB::rollBack();
}
}
@@ -0,0 +1,57 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
trait HasAction
{
protected Closure | string | null $action = null;
protected bool | Closure | null $isLivewireClickHandlerEnabled = null;
public function action(Closure | string | null $action): static
{
$this->action = $action;
return $this;
}
public function livewireClickHandlerEnabled(bool | Closure | null $condition = true): static
{
$this->isLivewireClickHandlerEnabled = $condition;
return $this;
}
public function getActionFunction(): ?Closure
{
if (! $this->action instanceof Closure) {
return null;
}
return $this->action;
}
public function isLivewireClickHandlerEnabled(): bool
{
if (($isLivewireClickHandlerEnabled = $this->evaluate($this->isLivewireClickHandlerEnabled)) !== null) {
return (bool) $isLivewireClickHandlerEnabled;
}
if (filled($this->getUrl())) {
return false;
}
if ($this->canSubmitForm()) {
return false;
}
return true;
}
public function hasAction(): bool
{
return $this->action !== null;
}
}
@@ -0,0 +1,72 @@
<?php
namespace Filament\Actions\Concerns;
trait HasArguments
{
/**
* @var array<string, mixed> | null
*/
protected ?array $arguments = null;
/**
* @param array<string, mixed> | null $arguments
*/
public function arguments(?array $arguments): static
{
if ($arguments === null) {
$this->arguments = null;
return $this;
}
$this->arguments = [];
$this->mergeArguments($arguments);
return $this;
}
/**
* @param array<string, mixed> $arguments
*/
public function mergeArguments(array $arguments): static
{
$this->arguments = [
...$this->arguments ?? [],
...$arguments,
];
return $this;
}
/**
* @return array<string, mixed>
*/
public function getArguments(): array
{
$arguments = $this->arguments ?? [];
$nestingIndex = $this->getNestingIndex();
if (blank($nestingIndex)) {
return $arguments;
}
return [
...$this->getLivewire()->mountedActions[$nestingIndex]['arguments'] ?? [],
...$arguments,
];
}
public function resetArguments(): static
{
$this->arguments(null);
return $this;
}
public function hasArguments(): bool
{
return $this->arguments !== null;
}
}
@@ -0,0 +1,113 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
trait HasData
{
/**
* @var array<string, mixed>
*/
protected array $data = [];
protected ?Closure $mutateDataUsing = null;
public function mutateDataUsing(?Closure $callback): static
{
$this->mutateDataUsing = $callback;
return $this;
}
/**
* @deprecated Use `mutateDataUsing()` instead.
*/
public function mutateFormDataUsing(?Closure $callback): static
{
$this->mutateDataUsing($callback);
return $this;
}
/**
* @param array<string, mixed> $data
*/
public function data(array $data, bool $shouldMutate = true): static
{
if ($shouldMutate && $this->mutateDataUsing) {
$data = $this->evaluate($this->mutateDataUsing, [
'data' => $data,
]);
}
$this->data = $data;
return $this;
}
/**
* @deprecated Use `data()` instead.
*
* @param array<string, mixed> $data
*/
public function formData(array $data, bool $shouldMutate = true): static
{
$this->data($data, $shouldMutate);
return $this;
}
public function resetData(): static
{
$this->data([], shouldMutate: false);
return $this;
}
/**
* @deprecated Use `resetData()` instead.
*/
public function resetFormData(): static
{
$this->resetData();
return $this;
}
/**
* @return array<string, mixed>
*/
public function getData(): array
{
return $this->data;
}
/**
* @deprecated Use `getData()` instead.
*
* @return array<string, mixed>
*/
public function getFormData(): array
{
return $this->getData();
}
/**
* @return array<string, mixed>
*/
public function getRawData(): array
{
return $this->getLivewire()->mountedActions[$this->getNestingIndex()]['data'] ?? [];
}
/**
* @deprecated Use `getRawData()` instead.
*
* @return array<string, mixed>
*/
public function getRawFormData(): array
{
return $this->getRawData();
}
}
@@ -0,0 +1,139 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
use Filament\Support\Enums\Width;
trait HasDropdown
{
protected bool | Closure $hasDropdown = true;
protected string | Closure | null $dropdownPlacement = null;
protected string | Closure | null $defaultDropdownPlacement = null;
protected string | Closure | null $dropdownMaxHeight = null;
protected int | Closure | null $dropdownOffset = null;
protected Width | string | Closure | null $dropdownWidth = null;
protected bool | Closure $hasDropdownFlip = true;
protected bool | Closure | null $hasDropdownTeleport = null;
protected bool | Closure | null $hasDefaultDropdownTeleport = null;
public function dropdown(bool | Closure $condition = true): static
{
$this->hasDropdown = $condition;
return $this;
}
public function dropdownPlacement(string | Closure | null $placement): static
{
$this->dropdownPlacement = $placement;
return $this;
}
public function dropdownAutoPlacement(): static
{
$this->dropdownPlacement('auto-placement');
$this->dropdownFlip(false);
return $this;
}
public function defaultDropdownPlacement(string | Closure | null $placement): static
{
$this->defaultDropdownPlacement = $placement;
return $this;
}
public function dropdownMaxHeight(string | Closure | null $height): static
{
$this->dropdownMaxHeight = $height;
return $this;
}
public function dropdownOffset(int | Closure | null $offset): static
{
$this->dropdownOffset = $offset;
return $this;
}
public function dropdownWidth(Width | string | Closure | null $width): static
{
$this->dropdownWidth = $width;
return $this;
}
public function dropdownFlip(bool | Closure $condition = true): static
{
$this->hasDropdownFlip = $condition;
return $this;
}
public function dropdownTeleport(bool | Closure | null $condition = true): static
{
$this->hasDropdownTeleport = $condition;
return $this;
}
public function defaultDropdownTeleport(bool | Closure | null $condition = true): static
{
$this->hasDefaultDropdownTeleport = $condition;
return $this;
}
public function getDropdownPlacement(): ?string
{
return $this->evaluate($this->dropdownPlacement) ?? $this->evaluate($this->defaultDropdownPlacement);
}
public function getDropdownMaxHeight(): ?string
{
return $this->evaluate($this->dropdownMaxHeight);
}
public function getDropdownOffset(): ?int
{
return $this->evaluate($this->dropdownOffset);
}
public function getDropdownWidth(): Width | string | null
{
$width = $this->evaluate($this->dropdownWidth);
if (is_string($width)) {
$width = Width::tryFrom($width) ?? $width;
}
return $width;
}
public function hasDropdownFlip(): bool
{
return (bool) $this->evaluate($this->hasDropdownFlip);
}
public function hasDropdownTeleport(): bool
{
return (bool) ($this->evaluate($this->hasDropdownTeleport) ?? $this->evaluate($this->hasDefaultDropdownTeleport));
}
public function hasDropdown(): bool
{
return (bool) $this->evaluate($this->hasDropdown);
}
}
@@ -0,0 +1,47 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
use Illuminate\View\ComponentAttributeBag;
trait HasExtraModalWindowAttributes
{
/**
* @var array<array<mixed> | Closure>
*/
protected array $extraModalWindowAttributes = [];
/**
* @param array<mixed> | Closure $attributes
*/
public function extraModalWindowAttributes(array | Closure $attributes, bool $merge = false): static
{
if ($merge) {
$this->extraModalWindowAttributes[] = $attributes;
} else {
$this->extraModalWindowAttributes = [$attributes];
}
return $this;
}
/**
* @return array<mixed>
*/
public function getExtraModalWindowAttributes(): array
{
$temporaryAttributeBag = new ComponentAttributeBag;
foreach ($this->extraModalWindowAttributes as $extraModalWindowAttributes) {
$temporaryAttributeBag = $temporaryAttributeBag->merge($this->evaluate($extraModalWindowAttributes), escape: false);
}
return $temporaryAttributeBag->getAttributes();
}
public function getExtraModalWindowAttributeBag(): ComponentAttributeBag
{
return new ComponentAttributeBag($this->getExtraModalWindowAttributes());
}
}
@@ -0,0 +1,24 @@
<?php
namespace Filament\Actions\Concerns;
use BackedEnum;
use Closure;
use Illuminate\Contracts\Support\Htmlable;
trait HasGroupedIcon
{
protected string | BackedEnum | Htmlable | Closure | null $groupedIcon = null;
public function groupedIcon(string | BackedEnum | Htmlable | Closure | null $icon): static
{
$this->groupedIcon = $icon;
return $this;
}
public function getGroupedIcon(): string | BackedEnum | Htmlable | null
{
return $this->evaluate($this->groupedIcon);
}
}
@@ -0,0 +1,20 @@
<?php
namespace Filament\Actions\Concerns;
trait HasId
{
protected ?string $id = null;
public function id(string $id): static
{
$this->id = $id;
return $this;
}
public function getId(): string
{
return $this->id ?? $this->getName();
}
}
@@ -0,0 +1,23 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Schemas\Components\Component;
trait HasInfolist
{
/**
* @deprecated Use `schema() instead.
*
* @param array<Component | Action | ActionGroup> | Closure | null $infolist
*/
public function infolist(array | Closure | null $infolist): static
{
$this->schema($infolist);
return $this;
}
}
@@ -0,0 +1,34 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
use Illuminate\Support\Arr;
trait HasKeyBindings
{
/**
* @var string | array<string> | Closure | null
*/
protected string | array | Closure | null $keyBindings = null;
/**
* @param string | array<string> | Closure | null $bindings
*/
public function keyBindings(string | array | Closure | null $bindings): static
{
$this->keyBindings = $bindings;
return $this;
}
/**
* @return array<string> | null
*/
public function getKeyBindings(): ?array
{
$keyBindings = Arr::wrap($this->evaluate($this->keyBindings));
return count($keyBindings) ? $keyBindings : null;
}
}
@@ -0,0 +1,64 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
use Illuminate\Contracts\Support\Htmlable;
trait HasLabel
{
protected string | Htmlable | Closure | null $label = null;
protected bool | Closure $isLabelHidden = false;
protected bool $shouldTranslateLabel = false;
/**
* @deprecated Use `hiddenLabel()` instead.
*/
public function disableLabel(bool | Closure $condition = true): static
{
$this->hiddenLabel($condition);
return $this;
}
public function hiddenLabel(bool | Closure $condition = true): static
{
$this->isLabelHidden = $condition;
return $this;
}
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 | null
{
$label = $this->evaluate($this->label) ?? (string) str($this->getName())
->before('.')
->kebab()
->replace(['-', '_'], ' ')
->ucfirst();
return is_string($label) && $this->shouldTranslateLabel
? __($label)
: $label;
}
public function isLabelHidden(): bool
{
return (bool) $this->evaluate($this->isLabelHidden);
}
}
@@ -0,0 +1,101 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
use Filament\Actions\Events\ActionCalled;
use Filament\Actions\Events\ActionCalling;
use Illuminate\Support\Facades\Event;
trait HasLifecycleHooks
{
protected ?Closure $before = null;
protected ?Closure $after = null;
protected ?Closure $beforeFormFilled = null;
protected ?Closure $afterFormFilled = null;
protected ?Closure $beforeFormValidated = null;
protected ?Closure $afterFormValidated = null;
public function before(?Closure $callback): static
{
$this->before = $callback;
return $this;
}
public function after(?Closure $callback): static
{
$this->after = $callback;
return $this;
}
public function beforeFormFilled(?Closure $callback): static
{
$this->beforeFormFilled = $callback;
return $this;
}
public function afterFormFilled(?Closure $callback): static
{
$this->afterFormFilled = $callback;
return $this;
}
public function beforeFormValidated(?Closure $callback): static
{
$this->beforeFormValidated = $callback;
return $this;
}
public function afterFormValidated(?Closure $callback): static
{
$this->afterFormValidated = $callback;
return $this;
}
public function callBefore(): mixed
{
Event::dispatch(ActionCalling::class, $this);
return $this->evaluate($this->before);
}
public function callAfter(): mixed
{
try {
return $this->evaluate($this->after);
} finally {
Event::dispatch(ActionCalled::class, $this);
}
}
public function callBeforeFormFilled(): mixed
{
return $this->evaluate($this->beforeFormFilled);
}
public function callAfterFormFilled(): mixed
{
return $this->evaluate($this->afterFormFilled);
}
public function callBeforeFormValidated(): mixed
{
return $this->evaluate($this->beforeFormValidated);
}
public function callAfterFormValidated(): mixed
{
return $this->evaluate($this->afterFormValidated);
}
}
@@ -0,0 +1,21 @@
<?php
namespace Filament\Actions\Concerns;
trait HasMountableArguments
{
/**
* @param array<string, mixed> $arguments
*/
public function __invoke(array $arguments): static
{
// Clone the action so that we don't accidentally mutate
// the cached action's arguments while rendering it,
// especially if it's mounted with different arguments.
$action = clone $this;
$action->arguments($arguments);
return $action;
}
}
@@ -0,0 +1,28 @@
<?php
namespace Filament\Actions\Concerns;
use LogicException;
trait HasName
{
protected ?string $name = null;
public function name(?string $name): static
{
$this->name = $name;
return $this;
}
public function getName(): ?string
{
if (blank($this->name)) {
$actionClass = static::class;
throw new LogicException("Action of class [$actionClass] must have a unique name, passed to the [make()] method.");
}
return $this->name;
}
}
@@ -0,0 +1,27 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
trait HasParentActions
{
protected bool | string | Closure | null $cancelParentActions = null;
public function cancelParentActions(bool | string | Closure | null $toAction = true): static
{
$this->cancelParentActions = $toAction;
return $this;
}
public function shouldCancelAllParentActions(): bool
{
return $this->evaluate($this->cancelParentActions) === true;
}
public function getParentActionToCancelTo(): ?string
{
return $this->evaluate($this->cancelParentActions);
}
}
@@ -0,0 +1,150 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Wizard;
use Filament\Schemas\Schema;
trait HasSchema
{
/**
* @var array<Component | Action | ActionGroup> | Closure | null
*/
protected array | Closure | null $schema = null;
protected bool | Closure $isSchemaDisabled = false;
protected bool | Closure | null $hasFormWrapper = null;
/**
* @param array<Component | Action | ActionGroup> | Closure | null $schema
*/
public function schema(array | Closure | null $schema): static
{
$this->schema = $schema;
return $this;
}
public function disabledSchema(bool | Closure $condition = true): static
{
$this->isSchemaDisabled = $condition;
return $this;
}
public function isSchemaDisabled(): bool
{
return (bool) $this->evaluate($this->isSchemaDisabled);
}
public function getSchema(Schema $schema): ?Schema
{
$modifiedSchema = $this->evaluate($this->schema ?? $this->getHasActionsLivewire()?->getDefaultActionSchemaResolver($this), [
'form' => $schema,
'schema' => $schema,
'infolist' => $schema,
]);
if ($modifiedSchema === null) {
return null;
}
if (is_array($modifiedSchema) && (! count($modifiedSchema))) {
return null;
}
if (is_array($modifiedSchema) && $this->isWizard()) {
$wizard = Wizard::make($modifiedSchema)
->contained(false)
->startOnStep($this->getWizardStartStep())
->cancelAction($this->getModalCancelAction())
->submitAction($this->getModalSubmitAction())
->alpineSubmitHandler("\$wire.{$this->getLivewireCallMountedActionName()}()")
->skippable($this->isWizardSkippable())
->disabled($this->isSchemaDisabled());
if ($this->modifyWizardUsing) {
$wizard = $this->evaluate($this->modifyWizardUsing, [
'wizard' => $wizard,
]) ?? $wizard;
}
$modifiedSchema = [$wizard];
}
if (is_array($modifiedSchema)) {
$modifiedSchema = $schema->components($modifiedSchema);
}
if ($this->isSchemaDisabled()) {
return $modifiedSchema->disabled();
}
return $modifiedSchema;
}
public function formWrapper(bool | Closure | null $condition = true): static
{
$this->hasFormWrapper = $condition;
return $this;
}
public function hasFormWrapper(): bool
{
return (bool) ($this->evaluate($this->hasFormWrapper) ?? (! $this->isWizard()));
}
/**
* @deprecated Use `disabledSchema() instead.
*/
public function disableForm(bool | Closure $condition = true): static
{
$this->disabledSchema($condition);
return $this;
}
/**
* @deprecated Use `disabledSchema() instead.
*/
public function disabledForm(bool | Closure $condition = true): static
{
$this->disabledSchema($condition);
return $this;
}
/**
* @deprecated Use `schema() instead.
*
* @param array<Component| Action> | Closure | null $form
*/
public function form(array | Closure | null $form): static
{
$this->schema($form);
return $this;
}
/**
* @deprecated Use `getSchema()` instead.
*/
public function getForm(Schema $schema): ?Schema
{
return $this->getSchema($schema);
}
/**
* @deprecated Use `isSchemaDisabled()` instead.
*/
public function isFormDisabled(): bool
{
return $this->isSchemaDisabled();
}
}
@@ -0,0 +1,37 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
use Filament\Support\Enums\Size;
trait HasSize
{
protected Size | string | Closure | null $defaultSize = null;
protected Size | string | Closure | null $size = null;
public function defaultSize(Size | string | Closure | null $size): static
{
$this->defaultSize = $size;
return $this;
}
public function size(Size | string | Closure | null $size): static
{
$this->size = $size;
return $this;
}
public function getDefaultSize(): Size | string | null
{
return $this->evaluate($this->defaultSize);
}
public function getSize(): Size | string | null
{
return $this->evaluate($this->size) ?? $this->getDefaultSize();
}
}
@@ -0,0 +1,24 @@
<?php
namespace Filament\Actions\Concerns;
use BackedEnum;
use Closure;
use Illuminate\Contracts\Support\Htmlable;
trait HasTableIcon
{
protected string | BackedEnum | Htmlable | Closure | null $tableIcon = null;
public function tableIcon(string | BackedEnum | Htmlable | Closure | null $icon): static
{
$this->tableIcon = $icon;
return $this;
}
public function getTableIcon(): string | BackedEnum | Htmlable | null
{
return $this->evaluate($this->tableIcon);
}
}
@@ -0,0 +1,31 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
use Illuminate\Contracts\Support\Htmlable;
trait HasTooltip
{
protected string | Htmlable | Closure | null $tooltip = null;
public function tooltip(string | Htmlable | Closure | null $tooltip): static
{
$this->tooltip = $tooltip;
return $this;
}
public function getTooltip(): string | Htmlable | null
{
if (
$this->hasAuthorizationTooltip() &&
($response = $this->getAuthorizationResponseWithMessage())->denied() &&
filled($responseMessage = $response->message())
) {
return $responseMessage;
}
return $this->evaluate($this->tooltip);
}
}
@@ -0,0 +1,65 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
use Filament\Schemas\Components\Wizard\Step;
trait HasWizard
{
protected bool $isWizard = false;
protected bool | Closure $isWizardSkippable = false;
protected int | Closure $wizardStartStep = 1;
protected ?Closure $modifyWizardUsing = null;
/**
* @param array<Step> | Closure $steps
*/
public function steps(array | Closure $steps): static
{
$this->isWizard = true;
$this->schema($steps);
return $this;
}
public function startOnStep(int | Closure $startStep): static
{
$this->wizardStartStep = $startStep;
return $this;
}
public function skippableSteps(bool | Closure $condition = true): static
{
$this->isWizardSkippable = $condition;
return $this;
}
public function isWizard(): bool
{
return $this->isWizard;
}
public function isWizardSkippable(): bool
{
return (bool) $this->evaluate($this->isWizardSkippable);
}
public function modifyWizardUsing(?Closure $callback): static
{
$this->isWizard = true;
$this->modifyWizardUsing = $callback;
return $this;
}
public function getWizardStartStep(): int
{
return $this->evaluate($this->wizardStartStep);
}
}
@@ -0,0 +1,831 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;
use DanHarrin\LivewireRateLimiting\WithRateLimiting;
use Filament\Actions\Action;
use Filament\Actions\Enums\ActionStatus;
use Filament\Actions\Exceptions\ActionNotResolvableException;
use Filament\Schemas\Components\Contracts\ExposesStateToActionData;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Schemas\Schema;
use Filament\Support\Exceptions\Cancel;
use Filament\Support\Exceptions\Halt;
use Filament\Support\Livewire\Partials\PartialsComponentHook;
use Filament\Tables\Contracts\HasTable;
use Illuminate\Auth\Access\Response;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Url;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionUnionType;
use Throwable;
use function Livewire\store;
trait InteractsWithActions
{
use WithRateLimiting;
/**
* @var array<array<string, mixed>> | null
*/
public ?array $mountedActions = [];
protected ?int $originallyMountedActionIndex = null;
/**
* @var mixed
*/
#[Url(as: 'action')]
public $defaultAction = null;
/**
* @var mixed
*/
#[Url(as: 'actionArguments')]
public $defaultActionArguments = null;
/**
* @var mixed
*/
#[Url(as: 'actionContext')]
public $defaultActionContext = null;
/**
* @var mixed
*/
#[Url(as: 'tableAction')]
public $defaultTableAction = null;
/**
* @var mixed
*/
#[Url(as: 'tableActionRecord')]
public $defaultTableActionRecord = null;
/**
* @var mixed
*/
#[Url(as: 'tableActionArguments')]
public $defaultTableActionArguments = null;
/**
* @var array<string, Action>
*/
protected array $cachedActions = [];
/**
* @var array<Action> | null
*/
protected ?array $cachedMountedActions = null;
protected bool $hasActionsModalRendered = false;
public function bootedInteractsWithActions(): void
{
if (filled($originallyMountedActionIndex = array_key_last($this->mountedActions))) {
$this->originallyMountedActionIndex = $originallyMountedActionIndex;
}
$this->cacheTraitActions();
// Boot the InteractsWithTable trait first so the table object is available.
if (! ($this instanceof HasTable)) {
$this->cacheMountedActions($this->mountedActions);
}
}
/**
* @param array<string, mixed> $arguments
* @param array<string, mixed> $context
*/
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
{
$this->mountedActions[] = [
'name' => $name,
'arguments' => $arguments,
'context' => $context,
];
try {
$action = $this->getMountedAction();
} catch (ActionNotResolvableException $exception) {
$action = null;
}
if (! $action) {
$this->unmountAction(canCancelParentActions: false);
return null;
}
if ($action->isDisabled()) {
$this->unmountAction(canCancelParentActions: false);
return null;
}
if (($actionComponent = $action->getSchemaComponent()) instanceof ExposesStateToActionData) {
foreach ($actionComponent->getChildSchemas() as $actionComponentChildSchema) {
$actionComponentChildSchema->validate();
}
}
try {
if (
$action->hasAuthorizationNotification() &&
($response = $action->getAuthorizationResponseWithMessage())->denied()
) {
$action->sendUnauthorizedNotification($response);
throw new Cancel;
}
$hasSchema = $this->mountedActionHasSchema(mountedAction: $action);
if ($hasSchema) {
$action->callBeforeFormFilled();
}
$schema = $this->getMountedActionSchema(mountedAction: $action);
$action->mount([
'form' => $schema,
'schema' => $schema,
]);
if ($hasSchema) {
$action->callAfterFormFilled();
}
} catch (Halt $exception) {
return null;
} catch (Cancel $exception) {
$this->unmountAction(canCancelParentActions: false);
return null;
}
if (! $this->mountedActionShouldOpenModal(mountedAction: $action)) {
return $this->callMountedAction();
}
$this->syncActionModals();
$this->resetErrorBag();
return null;
}
/**
* @param array<string, mixed> $arguments
*/
public function callMountedAction(array $arguments = []): mixed
{
$action = $this->getMountedAction();
if (! $action) {
return null;
}
$action->mergeArguments($arguments);
if ($action->isDisabled()) {
return null;
}
if (
$action->hasAuthorizationNotification() &&
(! $action->isAuthorized())
) {
return null;
}
if ($rateLimit = $action->getRateLimit()) {
try {
$this->rateLimit($rateLimit, method: json_encode(array_map(fn (array $action): array => Arr::except($action, ['data']), $this->mountedActions)));
} catch (TooManyRequestsException $exception) {
$action->sendRateLimitedNotification($exception);
return null;
}
}
$schema = $this->getMountedActionSchema(mountedAction: $action);
$originallyMountedActions = $this->mountedActions;
$result = null;
try {
$action->beginDatabaseTransaction();
$schemaState = [];
if (($actionComponent = $action->getSchemaComponent()) instanceof ExposesStateToActionData) {
foreach ($actionComponent->getChildSchemas() as $actionComponentChildSchema) {
$schemaState = [
...$schemaState,
...$actionComponentChildSchema->getState(),
];
}
}
if ($this->mountedActionHasSchema(mountedAction: $action)) {
$action->callBeforeFormValidated();
$schema->getState(afterValidate: function (array $state) use ($action, $schemaState): void {
$action->callAfterFormValidated();
$action->data([
...$schemaState,
...$state,
]);
$action->callBefore();
});
} else {
$action->data($schemaState);
$action->callBefore();
}
$result = $action->call([
'form' => $schema,
'schema' => $schema,
]);
$result = $action->callAfter() ?? $result;
$this->afterActionCalled($action);
(match ($action->getStatus()) {
ActionStatus::Success => function () use ($action): void {
$action->sendSuccessNotification();
$action->dispatchSuccessRedirect();
},
ActionStatus::Failure => function () use ($action): void {
$action->sendFailureNotification();
$action->dispatchFailureRedirect();
},
})();
} catch (Halt $exception) {
$exception->shouldRollbackDatabaseTransaction() ?
$action->rollBackDatabaseTransaction() :
$action->commitDatabaseTransaction();
return null;
} catch (Cancel $exception) {
$exception->shouldRollbackDatabaseTransaction() ?
$action->rollBackDatabaseTransaction() :
$action->commitDatabaseTransaction();
} catch (ValidationException $exception) {
$action->rollBackDatabaseTransaction();
if (! $this->mountedActionShouldOpenModal(mountedAction: $action)) {
$action->resetArguments();
$action->resetData();
$this->unmountAction();
}
throw $exception;
} catch (Throwable $exception) {
$action->rollBackDatabaseTransaction();
throw $exception;
}
$action->commitDatabaseTransaction();
if (store($this)->has('redirect')) {
$this->unmountAction();
return $result;
}
$this->partiallyRenderActionParentSchema($action);
$action->resetArguments();
$action->resetData();
$onlyActionNamesAndContexts = fn (array $actions): array => collect($actions)
->map(fn (array $action): array => Arr::only($action, ['name', 'context']))
->all();
// If the action was replaced while it was being called,
// we don't want to unmount it.
if ($onlyActionNamesAndContexts($originallyMountedActions) !== $onlyActionNamesAndContexts($this->mountedActions)) {
$action->clearRecordAfter();
return null;
}
$this->unmountAction();
return $result;
}
public function forceRender(): void
{
app(PartialsComponentHook::class)->forceRender($this);
}
protected function partiallyRenderActionParentSchema(Action $action): void
{
$actionSchema = $action->getSchemaContainer() ?? $action->getSchemaComponent()?->getContainer();
$schemaToPartiallyRender = null;
while ($actionSchema !== null) {
if ($actionSchema->shouldPartiallyRender()) {
$schemaToPartiallyRender = $actionSchema;
}
$actionSchema = $actionSchema->getParentComponent()?->getContainer();
}
if (! $schemaToPartiallyRender) {
return;
}
app(PartialsComponentHook::class)->renderPartial($this, fn (): array => [
"schema.{$schemaToPartiallyRender->getKey()}" => $schemaToPartiallyRender->toHtml(...),
]);
}
protected function afterActionCalled(Action $action): void {}
/**
* @param array<string, mixed> $arguments
* @param array<string, mixed> $context
*/
public function replaceMountedAction(string $name, array $arguments = [], array $context = []): void
{
$this->mountedActions = [];
$this->cachedMountedActions = null;
foreach ($this->cachedSchemas as $schemaName => $schema) {
if (str($schemaName)->startsWith('mountedActionSchema')) {
unset($this->cachedSchemas[$schemaName]);
}
}
$this->mountAction($name, $arguments, $context);
}
public function cacheAction(Action $action): Action
{
$action->livewire($this);
return $this->cachedActions[$action->getName()] = $action;
}
/**
* @param array<string, Action> $actions
*/
protected function mergeCachedActions(array $actions): void
{
$this->cachedActions = [
...$this->cachedActions,
...$actions,
];
}
public function mountedActionShouldOpenModal(?Action $mountedAction = null): bool
{
return ($mountedAction ?? $this->getMountedAction())->shouldOpenModal(
checkForSchemaUsing: $this->mountedActionHasSchema(...),
);
}
public function mountedActionHasSchema(?Action $mountedAction = null): bool
{
return (bool) count($this->getMountedActionSchema(mountedAction: $mountedAction)?->getComponents() ?? []);
}
/**
* @deprecated Use `mountedActionHasSchema()` instead.
*/
public function mountedActionHasForm(?Action $mountedAction = null): bool
{
return $this->mountedActionHasSchema($mountedAction);
}
/**
* @return array<Action>
*/
public function getMountedActions(): array
{
if (blank($this->mountedActions ?? [])) {
return [];
}
if (array_key_exists(count($this->mountedActions) - 1, $this->cachedMountedActions ?? [])) {
return $this->cachedMountedActions;
}
return $this->cacheMountedActions($this->mountedActions);
}
public function cacheTraitActions(): void
{
foreach (class_uses_recursive($class = static::class) as $trait) {
$traitBasename = class_basename($trait);
if (
str($traitBasename)->endsWith('Actions') &&
method_exists($class, $method = "cache{$traitBasename}")
) {
$this->{$method}();
}
}
}
public function getMountedAction(?int $actionNestingIndex = null): ?Action
{
if (! count($this->mountedActions ?? [])) {
return null;
}
$actionNestingIndex ??= (count($this->mountedActions) - 1);
if (array_key_exists($actionNestingIndex, $this->cachedMountedActions ?? [])) {
return $this->cachedMountedActions[$actionNestingIndex];
}
$this->cacheMountedActions(
Arr::take($this->mountedActions, $actionNestingIndex + 1),
);
return Arr::last($this->cachedMountedActions);
}
/**
* @param array<string, mixed> $mountedActions
* @return array<Action>
*/
protected function cacheMountedActions(array $mountedActions): array
{
return $this->cachedMountedActions = $this->resolveActions($mountedActions);
}
/**
* @param array<array<string, mixed>> $actions
* @return array<Action>
*/
protected function resolveActions(array $actions): array
{
$resolvedActions = [];
foreach ($actions as $actionNestingIndex => $action) {
if (blank($action['name'] ?? null)) {
throw new ActionNotResolvableException('An action tried to resolve without a name.');
}
if (filled($action['context']['schemaComponent'] ?? null)) {
$resolvedAction = $this->resolveSchemaComponentAction($action, $resolvedActions);
} elseif ($this instanceof HasTable && filled($action['context']['table'] ?? null)) {
$resolvedAction = $this->resolveTableAction($action, $resolvedActions);
} else {
$resolvedAction = $this->resolveAction($action, $resolvedActions);
}
if (! $resolvedAction) {
continue;
}
$resolvedAction->nestingIndex($actionNestingIndex);
$resolvedAction->boot();
$resolvedActions[] = $resolvedAction;
$this->cacheSchema(
"mountedActionSchema{$actionNestingIndex}",
$this->getMountedActionSchema($actionNestingIndex, $resolvedAction),
);
}
return $resolvedActions;
}
/**
* @param array<string, mixed> $action
* @param array<Action> $parentActions
*/
protected function resolveAction(array $action, array $parentActions): ?Action
{
if (count($parentActions)) {
$parentAction = Arr::last($parentActions);
$resolvedAction = $parentAction->getModalAction($action['name']) ?? throw new ActionNotResolvableException("Action [{$action['name']}] was not found for action [{$parentAction->getName()}].");
} elseif (array_key_exists($action['name'], $this->cachedActions)) {
$resolvedAction = $this->cachedActions[$action['name']];
} else {
if (
(! str($action['name'])->endsWith('Action')) &&
method_exists($this, "{$action['name']}Action")
) {
$methodName = "{$action['name']}Action";
} elseif (method_exists($this, $action['name'])) {
$methodName = $action['name'];
} else {
return null;
}
$returnTypeReflection = (new ReflectionMethod($this, $methodName))->getReturnType();
if (! $returnTypeReflection) {
return null;
}
$returnTypes = $returnTypeReflection instanceof ReflectionUnionType ? $returnTypeReflection->getTypes() : [$returnTypeReflection];
$hasActionReturnType = collect($returnTypes)
->filter(fn ($returnType) => $returnType instanceof ReflectionNamedType)
->contains(fn (ReflectionNamedType $returnType) => is_a($returnType->getName(), Action::class, allow_string: true));
if (! $hasActionReturnType) {
return null;
}
$resolvedAction = $this->{$methodName}();
if (! $resolvedAction instanceof Action) {
throw new ActionNotResolvableException('Actions must be an instance of ' . Action::class . ". The [{$methodName}] method on the Livewire component returned an instance of [" . get_class($resolvedAction) . '].');
}
$this->cacheAction($resolvedAction);
}
return $resolvedAction;
}
/**
* @param array<string, mixed> $action
* @param array<Action> $parentActions
*/
protected function resolveTableAction(array $action, array $parentActions): Action
{
if (! ($this instanceof HasTable)) {
throw new ActionNotResolvableException('Failed to resolve table action for Livewire component without the [' . HasTable::class . '] trait.');
}
$resolvedAction = null;
if (count($parentActions)) {
$parentAction = Arr::last($parentActions);
$resolvedAction = $parentAction->getModalAction($action['name']) ?? throw new ActionNotResolvableException("Action [{$action['name']}] was not found for action [{$parentAction->getName()}].");
} else {
if ($action['context']['bulk'] ?? false) {
$resolvedAction = $this->getTable()->getBulkAction($action['name']);
}
$resolvedAction ??= $this->getTable()->getAction($action['name']) ?? throw new ActionNotResolvableException("Action [{$action['name']}] not found on table.");
}
if (filled($action['context']['recordKey'] ?? null)) {
$record = $this->getTableRecord($action['context']['recordKey']);
$resolvedAction->getRootGroup()?->record($record) ?? $resolvedAction->record($record);
}
return $resolvedAction;
}
/**
* @param array<string, mixed> $action
* @param array<Action> $parentActions
*/
protected function resolveSchemaComponentAction(array $action, array $parentActions): Action
{
if (! $this instanceof HasSchemas) {
throw new ActionNotResolvableException('Failed to resolve action schema for Livewire component without the [' . InteractsWithSchemas::class . '] trait.');
}
$key = $action['context']['schemaComponent'];
$schemaName = (string) str($key)->before('.');
$schema = $this->getSchema($schemaName);
if (! $schema) {
throw new ActionNotResolvableException("Schema [{$schemaName}] not found.");
}
$resolvedAction = $schema->getAction(
$action['name'],
str($key)->contains('.') ? (string) str($key)->after('.') : null,
);
if (! $resolvedAction) {
throw new ActionNotResolvableException("Action [{$action['name']}] not found in schema at [{$action['context']['schemaComponent']}].");
}
return $resolvedAction;
}
/**
* @param string | array<string> $actions
*/
public function getAction(string | array $actions): ?Action
{
$actions = array_map(
fn (string | array $action): array => is_array($action) ? $action : ['name' => $action],
Arr::wrap($actions),
);
return Arr::last($this->resolveActions($actions));
}
public function getMountedActionSchemaName(): ?string
{
if (empty($this->mountedActions)) {
return null;
}
return 'mountedActionSchema' . array_key_last($this->mountedActions);
}
protected function getMountedActionSchema(?int $actionNestingIndex = null, ?Action $mountedAction = null): ?Schema
{
$actionNestingIndex ??= $mountedAction?->getNestingIndex() ?? array_key_last($this->mountedActions);
$mountedAction ??= $this->getMountedAction($actionNestingIndex);
if (! ($mountedAction instanceof Action)) {
return null;
}
if ((! $this->isCachingSchemas) && $this->hasCachedSchema("mountedActionSchema{$actionNestingIndex}")) {
return $this->getSchema("mountedActionSchema{$actionNestingIndex}");
}
return $mountedAction->getSchema(
$this->makeSchema()
->model(function () use ($mountedAction): Model | array | string | null {
$schemaComponent = $mountedAction->getSchemaComponent();
return $mountedAction->getRecord(withDefault: blank($schemaComponent)) ?? $mountedAction->getModel(withDefault: blank($schemaComponent)) ?? $schemaComponent?->getActionSchemaModel() ?? $this->getMountedActionSchemaModel();
})
->key("mountedActionSchema{$actionNestingIndex}")
->statePath("mountedActions.{$actionNestingIndex}.data")
->operation(
collect($this->mountedActions)
->take($actionNestingIndex + 1)
->pluck('name')
->implode('.'),
)
->rootHeadingLevel(3),
);
}
/**
* @deprecated Use `getMountedActionSchema()` instead.
*/
protected function getMountedActionForm(?int $actionNestingIndex = null, ?Action $mountedAction = null): ?Schema
{
return $this->getMountedActionSchema($actionNestingIndex, $mountedAction);
}
/**
* @return Model|class-string<Model>|null
*/
protected function getMountedActionSchemaModel(): Model | string | null
{
return null;
}
public function unmountAction(bool $canCancelParentActions = true): void
{
try {
$action = $this->getMountedAction();
} catch (ActionNotResolvableException $exception) {
$action = null;
}
if (! ($canCancelParentActions && $action)) {
array_pop($this->mountedActions);
} elseif ($action->shouldCancelAllParentActions()) {
$this->mountedActions = [];
} else {
$parentActionToCancelTo = $action->getParentActionToCancelTo();
while (true) {
$recentlyClosedParentAction = array_pop($this->mountedActions);
if (
blank($parentActionToCancelTo) ||
($recentlyClosedParentAction['name'] === $parentActionToCancelTo)
) {
break;
}
}
}
$this->syncActionModals();
while (count($this->cachedMountedActions ?? []) > count($this->mountedActions)) {
array_pop($this->cachedMountedActions);
}
if (! count($this->mountedActions)) {
$action?->clearRecordAfter();
// Setting these to `null` creates a bug where the properties are
// actually set to `'null'` strings and remain in the URL.
$this->defaultAction = [];
$this->defaultActionArguments = [];
$this->defaultActionContext = [];
return;
}
$this->resetErrorBag();
}
protected function syncActionModals(): void
{
$this->dispatch('sync-action-modals', id: $this->getId(), newActionNestingIndex: array_key_last($this->mountedActions));
}
public function getOriginallyMountedActionIndex(): ?int
{
return $this->originallyMountedActionIndex;
}
/**
* @param array<string, mixed> $arguments
*/
public function mergeMountedActionArguments(array $arguments): void
{
$this->mountedActions[array_key_last($this->mountedActions)]['arguments'] = array_merge(
$this->mountedActions[array_key_last($this->mountedActions)]['arguments'],
$arguments,
);
$this->getMountedAction()->mergeArguments($arguments);
}
public function getDefaultActionRecord(Action $action): ?Model
{
return null;
}
public function getDefaultActionRecordTitle(Action $action): ?string
{
return null;
}
/**
* @return ?class-string<Model>
*/
public function getDefaultActionModel(Action $action): ?string
{
return null;
}
public function getDefaultActionModelLabel(Action $action): ?string
{
return null;
}
public function getDefaultActionUrl(Action $action): ?string
{
return null;
}
public function getDefaultActionSuccessRedirectUrl(Action $action): ?string
{
return null;
}
public function getDefaultActionFailureRedirectUrl(Action $action): ?string
{
return null;
}
public function getDefaultActionRelationship(Action $action): ?Relation
{
return null;
}
public function getDefaultActionSchemaResolver(Action $action): ?Closure
{
return null;
}
public function getDefaultActionAuthorizationResponse(Action $action): ?Response
{
return null;
}
public function getDefaultActionIndividualRecordAuthorizationResponseResolver(Action $action): ?Closure
{
return null;
}
}
@@ -0,0 +1,363 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
use Filament\Actions\Action;
use Filament\Support\ArrayRecord;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use LogicException;
use function Filament\Support\get_model_label;
use function Filament\Support\locale_has_pluralization;
trait InteractsWithRecord
{
/**
* @var Model | class-string<Model> | array<string, mixed> | Closure | null
*/
protected Model | string | array | Closure | null $record = null;
protected ?Closure $resolveRecordUsing = null;
/**
* @var class-string<Model>|Closure|null
*/
protected string | Closure | null $model = null;
protected string | Closure | null $modelLabel = null;
protected string | Closure | null $pluralModelLabel = null;
protected string | Closure | null $recordTitle = null;
protected string | Closure | null $recordTitleAttribute = null;
/**
* @param Model | string | array<string, mixed> | Closure | null $record
*/
public function record(Model | string | array | Closure | null $record): static
{
$this->record = $record;
return $this;
}
public function resolveRecordUsing(?Closure $callback): static
{
$this->resolveRecordUsing = $callback;
return $this;
}
/**
* @param class-string<Model>|Closure|null $model
*/
public function model(string | Closure | null $model): static
{
$this->model = $model;
return $this;
}
public function modelLabel(string | Closure | null $label): static
{
$this->modelLabel = $label;
return $this;
}
public function pluralModelLabel(string | Closure | null $label): static
{
$this->pluralModelLabel = $label;
return $this;
}
public function recordTitle(string | Closure | null $title): static
{
$this->recordTitle = $title;
return $this;
}
public function recordTitleAttribute(string | Closure | null $attribute): static
{
$this->recordTitleAttribute = $attribute;
return $this;
}
/**
* @return Model | array<string, mixed> | null
*/
public function getRecord(bool $withDefault = true): Model | array | null
{
$record = $this->evaluate($this->record);
$isRecordKey = filled($record) && (! $record instanceof Model) && (! is_array($record));
if ($isRecordKey && (! $this->resolveRecordUsing)) {
throw new LogicException("Could not resolve record from key [{$record}] without a [resolveRecordUsing()] callback.");
}
if ($isRecordKey) {
$record = $this->evaluate($this->resolveRecordUsing, [
'key' => $record,
]);
}
if ($isRecordKey && $record && (! $this->record instanceof Closure)) {
$this->record = $record;
}
if ($record) {
return $record;
}
if ($record = $this->getGroup()?->getRecord($withDefault)) {
return $record;
}
if (($this instanceof Action) && $record = $this->getSchemaContainer()?->getRecord()) {
return $record;
}
if (($this instanceof Action) && $record = $this->getSchemaComponent()?->getRecord()) {
return $record;
}
return ($withDefault && ($this instanceof Action)) ? $this->getHasActionsLivewire()?->getDefaultActionRecord($this) : null;
}
public function getRecordTitle(?Model $record = null): ?string
{
$record ??= $this->getRecord();
if ($record) {
if (filled($title = $this->getCustomRecordTitle($record))) {
return $title;
}
if (filled($title = $this->getTable()?->getRecordTitle($record))) {
return $title;
}
}
if ($this instanceof Action && filled($title = $this->getHasActionsLivewire()?->getDefaultActionRecordTitle($this))) {
return $title;
}
return $this->getModelLabel();
}
/**
* @param Model | array<string, mixed> $record
*/
public function resolveRecordKey(Model | array $record): ?string
{
if (is_array($record)) {
return strval($record[ArrayRecord::getKeyName()] ?? throw new LogicException('Record arrays must have a unique [' . ArrayRecord::getKeyName() . '] entry for identification.'));
}
$key = $record->getKey();
if (blank($key)) {
return null;
}
return strval($key);
}
public function getCustomRecordTitle(?Model $record = null): ?string
{
$record ??= $this->getRecord();
$title = $this->evaluate(
$this->recordTitle,
namedInjections: [
'record' => $record,
],
typedInjections: ($record instanceof Model) ? [
Model::class => $record,
$record::class => $record,
] : [],
);
if (filled($title)) {
return $title;
}
$titleAttribute = $this->getCustomRecordTitleAttribute();
if (blank($titleAttribute)) {
return null;
}
if (str_contains($titleAttribute, '->')) {
$titleAttribute = str_replace('->', '.', $titleAttribute);
}
return data_get($record, $titleAttribute);
}
public function getRecordTitleAttribute(): ?string
{
return $this->getCustomRecordTitleAttribute() ?? $this->getTable()?->getRecordTitleAttribute();
}
public function getCustomRecordTitleAttribute(): ?string
{
return $this->evaluate($this->recordTitleAttribute);
}
public function hasCustomRecordTitle(): bool
{
return filled($this->recordTitle);
}
public function hasCustomRecordTitleAttribute(): bool
{
return $this->recordTitleAttribute !== null;
}
public function hasRecord(): bool
{
return filled($this->record);
}
/**
* @return class-string<Model>|null
*/
public function getModel(bool $withDefault = true): ?string
{
$model = $this->getCustomModel();
if (filled($model)) {
return $model;
}
$model = $this->getTable()?->getModel();
if (filled($model)) {
return $model;
}
$record = $this->getRecord($withDefault);
if ($record instanceof Model) {
return $record::class;
}
if ($record = $this->getGroup()?->getModel($withDefault)) {
return $record;
}
if ($this instanceof Action && $model = $this->getSchemaContainer()?->getModel()) {
return $model;
}
if ($this instanceof Action && $model = $this->getSchemaComponent()?->getModel()) {
return $model;
}
return ($withDefault && ($this instanceof Action)) ? $this->getHasActionsLivewire()?->getDefaultActionModel($this) : null;
}
/**
* @return class-string<Model>|null
*/
public function getCustomModel(): ?string
{
return $this->evaluate($this->model);
}
public function getModelLabel(): ?string
{
$label = $this->getCustomModelLabel();
if (filled($label)) {
return $label;
}
$label = $this->getTable()?->getModelLabel();
if (filled($label)) {
return $label;
}
$model = $this->getModel();
if (! $model) {
return $this instanceof Action ? $this->getHasActionsLivewire()?->getDefaultActionModelLabel($this) : null;
}
$defaultModel = $this instanceof Action ? $this->getHasActionsLivewire()?->getDefaultActionModel($this) : null;
if (($this instanceof Action) && ($model === $defaultModel)) {
return $this->getHasActionsLivewire()?->getDefaultActionModelLabel($this);
}
return get_model_label($model);
}
public function getTitleCaseModelLabel(): ?string
{
$modelLabel = $this->getModelLabel();
if (blank($modelLabel)) {
return null;
}
return Str::ucwords($modelLabel);
}
public function getCustomModelLabel(): ?string
{
return $this->evaluate($this->modelLabel);
}
public function getPluralModelLabel(): ?string
{
$label = $this->getCustomPluralModelLabel();
if (filled($label)) {
return $label;
}
$label = $this->getTable()?->getPluralModelLabel();
if (filled($label)) {
return $label;
}
$singularLabel = $this->getModelLabel();
if (blank($singularLabel)) {
return null;
}
if (locale_has_pluralization()) {
return Str::plural($singularLabel);
}
return $singularLabel;
}
public function getTitleCasePluralModelLabel(): ?string
{
$pluralModelLabel = $this->getPluralModelLabel();
if (blank($pluralModelLabel)) {
return null;
}
return Str::ucwords($pluralModelLabel);
}
public function getCustomPluralModelLabel(): ?string
{
return $this->evaluate($this->pluralModelLabel);
}
}
@@ -0,0 +1,198 @@
<?php
namespace Filament\Actions\Concerns;
use Closure;
use Filament\Support\Authorization\DenyResponse;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
use Illuminate\Support\LazyCollection;
use LogicException;
trait InteractsWithSelectedRecords
{
protected bool | Closure $canAccessSelectedRecords = false;
protected int $totalSelectedRecordsCount = 0;
protected int $successfulSelectedRecordsCount = 0;
protected int $bulkAuthorizationFailureWithoutMessageCount = 0;
protected int $bulkProcessingFailureWithoutMessageCount = 0;
/**
* @var array<string>
*/
protected array $bulkAuthorizationFailureMessages = [];
/**
* @var array<string, array{message: string | Closure, count: int}>
*/
protected array $bulkProcessingFailureMessages = [];
public function accessSelectedRecords(bool | Closure $condition = true): static
{
$this->canAccessSelectedRecords = $condition;
return $this;
}
public function canAccessSelectedRecords(): bool
{
return (bool) $this->evaluate($this->canAccessSelectedRecords);
}
public function getSelectedRecords(): EloquentCollection | Collection | LazyCollection
{
if (! $this->canAccessSelectedRecords()) {
throw new LogicException("The action [{$this->getName()}] is attempting to access the selected records from the table, but it is not using [accessSelectedRecords()], so they are not available.");
}
$records = $this->getLivewire()->getSelectedTableRecords($this->shouldFetchSelectedRecords(), $this->getSelectedRecordsChunkSize());
$this->totalSelectedRecordsCount = ($records instanceof LazyCollection)
? $this->getLivewire()->getSelectedTableRecordsQuery(shouldFetchSelectedRecords: false)->count()
: $records->count();
$this->successfulSelectedRecordsCount = $this->totalSelectedRecordsCount;
return $records;
}
public function getSelectedRecordsQuery(): Builder
{
if (! $this->canAccessSelectedRecords()) {
throw new LogicException("The action [{$this->getName()}] is attempting to access the selected records query from the table, but it is not using [accessSelectedRecords()], so they are not available.");
}
return $this->getLivewire()->getSelectedTableRecordsQuery($this->shouldFetchSelectedRecords(), $this->getSelectedRecordsChunkSize());
}
public function getIndividuallyAuthorizedSelectedRecords(): EloquentCollection | Collection | LazyCollection
{
if (! $this->shouldAuthorizeIndividualRecords()) {
return $this->getSelectedRecords();
}
$this->successfulSelectedRecordsCount = 0;
$authorizationResponses = [];
$failureCountsByAuthorizationResponse = [];
$failureCountWithoutAuthorizationResponse = 0;
try {
return $this->getSelectedRecords()->filter(function ($record) use (&$authorizationResponses, &$failureCountsByAuthorizationResponse, &$failureCountWithoutAuthorizationResponse) {
$response = $this->getIndividualRecordAuthorizationResponse($record);
if ($response->allowed()) {
return true;
}
if ($response instanceof DenyResponse) {
$responseKey = $response->getKey();
$authorizationResponses[$responseKey] ??= $response;
$failureCountsByAuthorizationResponse[$responseKey] ??= 0;
$failureCountsByAuthorizationResponse[$responseKey]++;
} elseif (filled($responseMessage = $response->message())) {
$responseKey = array_search($responseMessage, $authorizationResponses);
if ($responseKey === false) {
$authorizationResponses[] = $responseMessage;
$responseKey = array_key_last($authorizationResponses);
$failureCountsByAuthorizationResponse[$responseKey] = 0;
}
$failureCountsByAuthorizationResponse[$responseKey]++;
} else {
$failureCountWithoutAuthorizationResponse++;
}
$this->successfulSelectedRecordsCount--;
return false;
});
} finally {
$failureMessages = [];
if ($this->totalSelectedRecordsCount > $this->successfulSelectedRecordsCount) {
foreach ($authorizationResponses as $responseKey => $response) {
if ($response instanceof DenyResponse) {
$failureMessages[] = $response->message($failureCountsByAuthorizationResponse[$responseKey], $this->totalSelectedRecordsCount);
} else {
$failureMessages[] = $response;
}
}
}
$this->bulkAuthorizationFailureWithoutMessageCount = $failureCountWithoutAuthorizationResponse;
$this->bulkAuthorizationFailureMessages = $failureMessages;
}
}
public function reportBulkProcessingFailure(?string $key = null, string | Closure | null $message = null): void
{
if (filled($key)) {
$this->bulkProcessingFailureMessages[$key] = [
'message' => $message,
'count' => ($this->bulkProcessingFailureMessages[$key]['count'] ?? 0) + 1,
];
} else {
$this->bulkProcessingFailureWithoutMessageCount++;
}
$this->successfulSelectedRecordsCount--;
}
public function reportBulkProcessingSuccessfulRecordsCount(int $count): void
{
$this->bulkProcessingFailureWithoutMessageCount = $this->successfulSelectedRecordsCount - $count;
$this->successfulSelectedRecordsCount = $count;
}
public function reportCompleteBulkProcessingFailure(?string $key = null, string | Closure | null $message = null): void
{
if (filled($key)) {
$this->bulkProcessingFailureMessages[$key] = [
'message' => $message,
'count' => $this->getTotalSelectedRecordsCount(),
];
} else {
$this->bulkProcessingFailureWithoutMessageCount += $this->getTotalSelectedRecordsCount();
}
$this->successfulSelectedRecordsCount = 0;
}
/**
* @return array<string>
*/
public function getBulkProcessingFailureMessages(): array
{
return array_reduce(
$this->bulkProcessingFailureMessages,
function (array $carry, array $failure): array {
if (blank($failure['message'])) {
return $carry;
}
$carry[] = $this->evaluate($failure['message'], [
'count' => $failure['count'],
'failureCount' => $failure['count'],
'isAll' => $failure['count'] === $this->totalSelectedRecordsCount,
'total' => $this->totalSelectedRecordsCount,
'totalCount' => $this->totalSelectedRecordsCount,
]);
return $carry;
},
initial: [],
);
}
public function getTotalSelectedRecordsCount(): int
{
return $this->totalSelectedRecordsCount;
}
}
@@ -0,0 +1,55 @@
<?php
namespace Filament\Actions\Contracts;
use Closure;
use Filament\Actions\Action;
use Illuminate\Auth\Access\Response;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
interface HasActions
{
/**
* @param string | array<string> $name
*/
public function getAction(string | array $name): ?Action;
/**
* @param array<string, mixed> $arguments
* @param array<string, mixed> $context
*/
public function mountAction(string $name, array $arguments = [], array $context = []): mixed;
/**
* @param array<string, mixed> $arguments
*/
public function mergeMountedActionArguments(array $arguments): void;
public function getDefaultActionRecord(Action $action): ?Model;
public function getDefaultActionRecordTitle(Action $action): ?string;
/**
* @return ?class-string<Model>
*/
public function getDefaultActionModel(Action $action): ?string;
public function getDefaultActionModelLabel(Action $action): ?string;
public function getDefaultActionUrl(Action $action): ?string;
public function getDefaultActionSuccessRedirectUrl(Action $action): ?string;
public function getDefaultActionFailureRedirectUrl(Action $action): ?string;
public function getDefaultActionRelationship(Action $action): ?Relation;
public function getDefaultActionSchemaResolver(Action $action): ?Closure;
public function getDefaultActionAuthorizationResponse(Action $action): ?Response;
public function getDefaultActionIndividualRecordAuthorizationResponseResolver(Action $action): ?Closure;
public function getMountedActionSchemaName(): ?string;
}
@@ -0,0 +1,221 @@
<?php
namespace Filament\Actions;
use Closure;
use Filament\Actions\Concerns\CanCustomizeProcess;
use Filament\Actions\Contracts\HasActions;
use Filament\Actions\View\ActionsIconAlias;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Schemas\Schema;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasOneOrManyThrough;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Arr;
class CreateAction extends Action
{
use CanCustomizeProcess;
protected bool | Closure $canCreateAnother = true;
protected bool | Closure $shouldForceRenderAfterCreateAnother = false;
protected ?Closure $modifyCreateAnotherActionUsing = null;
protected ?Closure $preserveFormDataWhenCreatingAnotherUsing = null;
protected ?Closure $getRelationshipUsing = null;
public static function getDefaultName(): ?string
{
return 'create';
}
protected function setUp(): void
{
parent::setUp();
$this->label(fn (): string => __('filament-actions::create.single.label', ['label' => $this->getModelLabel()]));
$this->modalHeading(fn (): string => __('filament-actions::create.single.modal.heading', ['label' => $this->getTitleCaseModelLabel()]));
$this->modalSubmitActionLabel(__('filament-actions::create.single.modal.actions.create.label'));
$this->extraModalFooterActions(function (): array {
return $this->canCreateAnother() ? [$this->getCreateAnotherAction()] : [];
});
$this->successNotificationTitle(__('filament-actions::create.single.notifications.created.title'));
$this->groupedIcon(FilamentIcon::resolve(ActionsIconAlias::CREATE_ACTION_GROUPED) ?? Heroicon::Plus);
$this->record(null);
$this->action(function (array $arguments, Schema $schema): void {
if ($arguments['another'] ?? false) {
$preserveRawState = $this->evaluate($this->preserveFormDataWhenCreatingAnotherUsing, [
'data' => $schema->getRawState(),
]) ?? [];
}
$model = $this->getModel();
$record = $this->process(function (array $data, HasActions & HasSchemas $livewire) use ($model): Model {
$relationship = $this->getRelationship();
$pivotData = [];
if ($relationship instanceof BelongsToMany) {
$pivotColumns = $relationship->getPivotColumns();
$pivotData = Arr::only($data, $pivotColumns);
$data = Arr::except($data, $pivotColumns);
}
if ($translatableContentDriver = $livewire->makeFilamentTranslatableContentDriver()) {
$record = $translatableContentDriver->makeRecord($model, $data);
} else {
$record = new $model;
$record->fill($data);
}
if (
(! $relationship) ||
($relationship instanceof HasOneOrManyThrough)
) {
$record->save();
return $record;
}
if ($relationship instanceof BelongsToMany) {
$relationship->save($record, $pivotData);
return $record;
}
/** @phpstan-ignore-next-line */
$relationship->save($record);
return $record;
});
$this->record($record);
$schema->model($record)->saveRelationships();
if ($arguments['another'] ?? false) {
if ($this->shouldForceRenderAfterCreateAnother()) {
$livewire = $this->getLivewire();
if (method_exists($livewire, 'forceRender')) {
$livewire->forceRender();
}
}
$this->callAfter();
$this->sendSuccessNotification();
$this->record(null);
// Ensure that the form record is anonymized so that relationships aren't loaded.
$schema->model($model);
$schema->fill();
$schema->rawState([
...$schema->getRawState(),
...$preserveRawState ?? [],
]);
$this->halt();
return;
}
$this->success();
});
}
/**
* @param array<string> $fields
*/
public function preserveFormDataWhenCreatingAnother(array | Closure | null $fields): static
{
$this->preserveFormDataWhenCreatingAnotherUsing = is_array($fields) ?
fn (array $data): array => Arr::only($data, $fields) :
$fields;
return $this;
}
public function relationship(?Closure $relationship): static
{
$this->getRelationshipUsing = $relationship;
return $this;
}
public function createAnother(bool | Closure $condition = true): static
{
$this->canCreateAnother = $condition;
return $this;
}
public function forceRenderAfterCreateAnother(bool | Closure $condition = true): static
{
$this->shouldForceRenderAfterCreateAnother = $condition;
return $this;
}
/**
* @deprecated Use `createAnother()` instead.
*/
public function disableCreateAnother(bool | Closure $condition = true): static
{
$this->createAnother(fn (CreateAction $action): bool => ! $action->evaluate($condition));
return $this;
}
public function canCreateAnother(): bool
{
return (bool) $this->evaluate($this->canCreateAnother);
}
public function createAnotherAction(Closure $callback): static
{
$this->modifyCreateAnotherActionUsing = $callback;
return $this;
}
public function getCreateAnotherAction(): Action
{
$action = $this->makeModalSubmitAction('createAnother', arguments: ['another' => true])
->label(__('filament-actions::create.single.modal.actions.create_another.label'));
return $this->evaluate($this->modifyCreateAnotherActionUsing, ['action' => $action]) ?? $action;
}
public function shouldClearRecordAfter(): bool
{
return true;
}
public function shouldForceRenderAfterCreateAnother(): bool
{
return (bool) $this->evaluate($this->shouldForceRenderAfterCreateAnother);
}
public function getRelationship(): Relation | Builder | null
{
return $this->evaluate($this->getRelationshipUsing) ?? $this->getTable()?->getRelationship() ?? $this->getHasActionsLivewire()?->getDefaultActionRelationship($this);
}
}
@@ -0,0 +1,63 @@
<?php
namespace Filament\Actions;
use Filament\Actions\Concerns\CanCustomizeProcess;
use Filament\Actions\View\ActionsIconAlias;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
use Illuminate\Database\Eloquent\Model;
class DeleteAction extends Action
{
use CanCustomizeProcess;
public static function getDefaultName(): ?string
{
return 'delete';
}
protected function setUp(): void
{
parent::setUp();
$this->label(__('filament-actions::delete.single.label'));
$this->modalHeading(fn (): string => __('filament-actions::delete.single.modal.heading', ['label' => $this->getRecordTitle()]));
$this->modalSubmitActionLabel(__('filament-actions::delete.single.modal.actions.delete.label'));
$this->successNotificationTitle(__('filament-actions::delete.single.notifications.deleted.title'));
$this->defaultColor('danger');
$this->tableIcon(FilamentIcon::resolve(ActionsIconAlias::DELETE_ACTION) ?? Heroicon::Trash);
$this->groupedIcon(FilamentIcon::resolve(ActionsIconAlias::DELETE_ACTION_GROUPED) ?? Heroicon::Trash);
$this->requiresConfirmation();
$this->modalIcon(FilamentIcon::resolve(ActionsIconAlias::DELETE_ACTION_MODAL) ?? Heroicon::OutlinedTrash);
$this->keyBindings(['mod+d']);
$this->hidden(static function (Model $record): bool {
if (! method_exists($record, 'trashed')) {
return false;
}
return $record->trashed();
});
$this->action(function (): void {
$result = $this->process(static fn (Model $record): ?bool => $record->delete());
if (! $result) {
$this->failure();
return;
}
$this->success();
});
}
}
@@ -0,0 +1,134 @@
<?php
namespace Filament\Actions;
use Filament\Actions\Concerns\CanCustomizeProcess;
use Filament\Actions\View\ActionsIconAlias;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\TrashedFilter;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\LazyCollection;
use Illuminate\Support\Number;
use Throwable;
class DeleteBulkAction extends BulkAction
{
use CanCustomizeProcess;
public static function getDefaultName(): ?string
{
return 'delete';
}
protected function setUp(): void
{
parent::setUp();
$this->label(__('filament-actions::delete.multiple.label'));
$this->modalHeading(fn (): string => __('filament-actions::delete.multiple.modal.heading', ['label' => $this->getTitleCasePluralModelLabel()]));
$this->modalSubmitActionLabel(__('filament-actions::delete.multiple.modal.actions.delete.label'));
$this->successNotificationTitle(__('filament-actions::delete.multiple.notifications.deleted.title'));
$this->failureNotificationTitle(function (int $successCount, int $totalCount): string {
if ($successCount) {
return trans_choice('filament-actions::delete.multiple.notifications.deleted_partial.title', $successCount, [
'count' => Number::format($successCount),
'total' => Number::format($totalCount),
]);
}
return trans_choice('filament-actions::delete.multiple.notifications.deleted_none.title', $totalCount, [
'count' => Number::format($totalCount),
'total' => Number::format($totalCount),
]);
});
$this->missingBulkAuthorizationFailureNotificationMessage(function (int $failureCount, int $totalCount): string {
return trans_choice(
($failureCount === $totalCount)
? 'filament-actions::delete.multiple.notifications.deleted_none.missing_authorization_failure_message'
: 'filament-actions::delete.multiple.notifications.deleted_partial.missing_authorization_failure_message',
$failureCount,
['count' => Number::format($failureCount)],
);
});
$this->missingBulkProcessingFailureNotificationMessage(function (int $failureCount, int $totalCount): string {
return trans_choice(
($failureCount === $totalCount)
? 'filament-actions::delete.multiple.notifications.deleted_none.missing_processing_failure_message'
: 'filament-actions::delete.multiple.notifications.deleted_partial.missing_processing_failure_message',
$failureCount,
['count' => Number::format($failureCount)],
);
});
$this->defaultColor('danger');
$this->icon(FilamentIcon::resolve(ActionsIconAlias::DELETE_ACTION) ?? Heroicon::Trash);
$this->requiresConfirmation();
$this->modalIcon(FilamentIcon::resolve(ActionsIconAlias::DELETE_ACTION_MODAL) ?? Heroicon::OutlinedTrash);
$this->action(function (): void {
$this->process(static function (DeleteBulkAction $action, EloquentCollection | Collection | LazyCollection $records): void {
if (! $action->shouldFetchSelectedRecords()) {
try {
$action->reportBulkProcessingSuccessfulRecordsCount(
$action->getSelectedRecordsQuery()->delete(),
);
} catch (Throwable $exception) {
$action->reportCompleteBulkProcessingFailure();
report($exception);
}
return;
}
$isFirstException = true;
$records->each(static function (Model $record) use ($action, &$isFirstException): void {
try {
$record->delete() || $action->reportBulkProcessingFailure();
} catch (Throwable $exception) {
$action->reportBulkProcessingFailure();
if ($isFirstException) {
// Only report the first exception to not flood error logs. Even if Filament
// did not catch exceptions like this, only the first would be reported
// as the rest of the process would be halted.
report($exception);
$isFirstException = false;
}
}
});
});
});
$this->deselectRecordsAfterCompletion();
$this->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
if (! array_key_exists('value', $trashedFilterState)) {
return false;
}
if ($trashedFilterState['value']) {
return false;
}
return filled($trashedFilterState['value']);
});
}
}
@@ -0,0 +1,57 @@
<?php
namespace Filament\Actions;
use Filament\Actions\Concerns\CanCustomizeProcess;
use Filament\Actions\View\ActionsIconAlias;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class DetachAction extends Action
{
use CanCustomizeProcess;
public static function getDefaultName(): ?string
{
return 'detach';
}
protected function setUp(): void
{
parent::setUp();
$this->label(__('filament-actions::detach.single.label'));
$this->modalHeading(fn (): string => __('filament-actions::detach.single.modal.heading', ['label' => $this->getRecordTitle()]));
$this->modalSubmitActionLabel(__('filament-actions::detach.single.modal.actions.detach.label'));
$this->successNotificationTitle(__('filament-actions::detach.single.notifications.detached.title'));
$this->defaultColor('danger');
$this->icon(FilamentIcon::resolve(ActionsIconAlias::DETACH_ACTION) ?? Heroicon::XMark);
$this->requiresConfirmation();
$this->modalIcon(FilamentIcon::resolve(ActionsIconAlias::DETACH_ACTION_MODAL) ?? Heroicon::OutlinedXMark);
$this->action(function (): void {
$this->process(function (Model $record, Table $table): void {
/** @var BelongsToMany $relationship */
$relationship = $table->getRelationship();
if ($table->allowsDuplicates()) {
$record->getRelationValue($relationship->getPivotAccessor())->delete();
} else {
$relationship->detach($record);
}
});
$this->success();
});
}
}
@@ -0,0 +1,92 @@
<?php
namespace Filament\Actions;
use Filament\Actions\Concerns\CanCustomizeProcess;
use Filament\Actions\View\ActionsIconAlias;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Collection;
use Illuminate\Support\LazyCollection;
use Throwable;
class DetachBulkAction extends BulkAction
{
use CanCustomizeProcess;
public static function getDefaultName(): ?string
{
return 'detach';
}
protected function setUp(): void
{
parent::setUp();
$this->label(__('filament-actions::detach.multiple.label'));
$this->modalHeading(fn (): string => __('filament-actions::detach.multiple.modal.heading', ['label' => $this->getTitleCasePluralModelLabel()]));
$this->modalSubmitActionLabel(__('filament-actions::detach.multiple.modal.actions.detach.label'));
$this->successNotificationTitle(__('filament-actions::detach.multiple.notifications.detached.title'));
$this->defaultColor('danger');
$this->icon(FilamentIcon::resolve(ActionsIconAlias::DETACH_ACTION) ?? Heroicon::XMark);
$this->requiresConfirmation();
$this->modalIcon(FilamentIcon::resolve(ActionsIconAlias::DETACH_ACTION_MODAL) ?? Heroicon::OutlinedXMark);
$this->action(function (): void {
$this->process(function (DetachBulkAction $action, EloquentCollection | Collection | LazyCollection $records, Table $table): void {
/** @var BelongsToMany $relationship */
$relationship = $table->getRelationship();
if (! $action->shouldFetchSelectedRecords()) {
try {
$action->reportBulkProcessingSuccessfulRecordsCount(
$relationship->detach($records),
);
} catch (Throwable $exception) {
$action->reportCompleteBulkProcessingFailure();
report($exception);
}
return;
}
$relationshipPivotAccessor = $relationship->getPivotAccessor();
$isFirstException = true;
$records->each(
function (Model $record) use ($action, &$isFirstException, $relationshipPivotAccessor): void {
try {
$record->getRelationValue($relationshipPivotAccessor)->delete();
} catch (Throwable $exception) {
$action->reportBulkProcessingFailure();
if ($isFirstException) {
// Only report the first exception so as to not flood error logs. Even
// if Filament did not catch exceptions like this, only the first
// would be reported as the rest of the process would be halted.
report($exception);
$isFirstException = false;
}
}
},
);
});
});
$this->deselectRecordsAfterCompletion();
}
}
@@ -0,0 +1,54 @@
<?php
namespace Filament\Actions;
use Filament\Actions\Concerns\CanCustomizeProcess;
use Filament\Actions\View\ActionsIconAlias;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DissociateAction extends Action
{
use CanCustomizeProcess;
public static function getDefaultName(): ?string
{
return 'dissociate';
}
protected function setUp(): void
{
parent::setUp();
$this->label(__('filament-actions::dissociate.single.label'));
$this->modalHeading(fn (): string => __('filament-actions::dissociate.single.modal.heading', ['label' => $this->getRecordTitle()]));
$this->modalSubmitActionLabel(__('filament-actions::dissociate.single.modal.actions.dissociate.label'));
$this->successNotificationTitle(__('filament-actions::dissociate.single.notifications.dissociated.title'));
$this->defaultColor('danger');
$this->icon(FilamentIcon::resolve(ActionsIconAlias::DISSOCIATE_ACTION) ?? Heroicon::XMark);
$this->requiresConfirmation();
$this->modalIcon(FilamentIcon::resolve(ActionsIconAlias::DISSOCIATE_ACTION_MODAL) ?? Heroicon::OutlinedXMark);
$this->action(function (): void {
$this->process(function (Model $record, Table $table): void {
/** @var BelongsTo $inverseRelationship */
$inverseRelationship = $table->getInverseRelationshipFor($record);
$inverseRelationship->dissociate();
$record->save();
});
$this->success();
});
}
}
@@ -0,0 +1,95 @@
<?php
namespace Filament\Actions;
use Filament\Actions\Concerns\CanCustomizeProcess;
use Filament\Actions\View\ActionsIconAlias;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
use Illuminate\Support\LazyCollection;
use Throwable;
class DissociateBulkAction extends BulkAction
{
use CanCustomizeProcess;
public static function getDefaultName(): ?string
{
return 'dissociate';
}
protected function setUp(): void
{
parent::setUp();
$this->label(__('filament-actions::dissociate.multiple.label'));
$this->modalHeading(fn (): string => __('filament-actions::dissociate.multiple.modal.heading', ['label' => $this->getTitleCasePluralModelLabel()]));
$this->modalSubmitActionLabel(__('filament-actions::dissociate.multiple.modal.actions.dissociate.label'));
$this->successNotificationTitle(__('filament-actions::dissociate.multiple.notifications.dissociated.title'));
$this->defaultColor('danger');
$this->icon(FilamentIcon::resolve(ActionsIconAlias::DISSOCIATE_ACTION) ?? Heroicon::XMark);
$this->requiresConfirmation();
$this->modalIcon(FilamentIcon::resolve(ActionsIconAlias::DISSOCIATE_ACTION_MODAL) ?? Heroicon::OutlinedXMark);
$this->action(function (): void {
$this->process(function (DissociateBulkAction $action, EloquentCollection | Collection | LazyCollection $records, Table $table): void {
if (! $action->shouldFetchSelectedRecords()) {
/** @var HasMany $relationship */
$relationship = $table->getRelationship();
try {
$action->reportBulkProcessingSuccessfulRecordsCount(
$action->getSelectedRecordsQuery()->update([
$relationship->getQualifiedForeignKeyName() => null,
]),
);
} catch (Throwable $exception) {
$action->reportCompleteBulkProcessingFailure();
report($exception);
}
return;
}
$isFirstException = true;
$records->each(function (Model $record) use ($action, &$isFirstException, $table): void {
try {
/** @var BelongsTo $inverseRelationship */
$inverseRelationship = $table->getInverseRelationshipFor($record);
$inverseRelationship->dissociate();
$record->save();
} catch (Throwable $exception) {
$action->reportBulkProcessingFailure();
if ($isFirstException) {
// Only report the first exception so as to not flood error logs. Even
// if Filament did not catch exceptions like this, only the first
// would be reported as the rest of the process would be halted.
report($exception);
$isFirstException = false;
}
}
});
});
});
$this->deselectRecordsAfterCompletion();
}
}
@@ -0,0 +1,121 @@
<?php
namespace Filament\Actions;
use Closure;
use Filament\Actions\Concerns\CanCustomizeProcess;
use Filament\Actions\Contracts\HasActions;
use Filament\Actions\View\ActionsIconAlias;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Arr;
class EditAction extends Action
{
use CanCustomizeProcess;
protected ?Closure $mutateRecordDataUsing = null;
public static function getDefaultName(): ?string
{
return 'edit';
}
protected function setUp(): void
{
parent::setUp();
$this->label(__('filament-actions::edit.single.label'));
$this->modalHeading(fn (): string => __('filament-actions::edit.single.modal.heading', ['label' => $this->getRecordTitle()]));
$this->modalSubmitActionLabel(__('filament-actions::edit.single.modal.actions.save.label'));
$this->successNotificationTitle(__('filament-actions::edit.single.notifications.saved.title'));
$this->defaultColor('primary');
$this->tableIcon(FilamentIcon::resolve(ActionsIconAlias::EDIT_ACTION) ?? Heroicon::PencilSquare);
$this->groupedIcon(FilamentIcon::resolve(ActionsIconAlias::EDIT_ACTION_GROUPED) ?? Heroicon::PencilSquare);
$this->fillForm(function (HasActions & HasSchemas $livewire, Model $record, ?Table $table): array {
$translatableContentDriver = $livewire->makeFilamentTranslatableContentDriver();
if ($translatableContentDriver) {
$data = $translatableContentDriver->getRecordAttributesToArray($record);
} else {
$data = $record->attributesToArray();
}
$relationship = $table?->getRelationship();
if ($relationship instanceof BelongsToMany) {
$pivot = $record->getRelationValue($relationship->getPivotAccessor());
$pivotColumns = $relationship->getPivotColumns();
if ($translatableContentDriver) {
$data = [
...$data,
...Arr::only($translatableContentDriver->getRecordAttributesToArray($pivot), $pivotColumns),
];
} else {
$data = [
...$data,
...Arr::only($pivot->attributesToArray(), $pivotColumns),
];
}
}
if ($this->mutateRecordDataUsing) {
$data = $this->evaluate($this->mutateRecordDataUsing, ['data' => $data]);
}
return $data;
});
$this->action(function (): void {
$this->process(function (array $data, HasActions & HasSchemas $livewire, Model $record, ?Table $table): void {
$relationship = $table?->getRelationship();
$translatableContentDriver = $livewire->makeFilamentTranslatableContentDriver();
if ($relationship instanceof BelongsToMany) {
$pivot = $record->getRelationValue($relationship->getPivotAccessor());
$pivotColumns = $relationship->getPivotColumns();
$pivotData = Arr::only($data, $pivotColumns);
if (count($pivotColumns)) {
if ($translatableContentDriver) {
$translatableContentDriver->updateRecord($pivot, $pivotData);
} else {
$pivot->update($pivotData);
}
}
$data = Arr::except($data, $pivotColumns);
}
if ($translatableContentDriver) {
$translatableContentDriver->updateRecord($record, $data);
} else {
$record->update($data);
}
});
$this->success();
});
}
public function mutateRecordDataUsing(?Closure $callback): static
{
$this->mutateRecordDataUsing = $callback;
return $this;
}
}
@@ -0,0 +1,10 @@
<?php
namespace Filament\Actions\Enums;
enum ActionStatus
{
case Success;
case Failure;
}
@@ -0,0 +1,20 @@
<?php
namespace Filament\Actions\Events;
use Filament\Actions\Action;
use Illuminate\Foundation\Events\Dispatchable;
class ActionCalled
{
use Dispatchable;
public function __construct(
protected Action $action,
) {}
public function getAction(): Action
{
return $this->action;
}
}
@@ -0,0 +1,20 @@
<?php
namespace Filament\Actions\Events;
use Filament\Actions\Action;
use Illuminate\Foundation\Events\Dispatchable;
class ActionCalling
{
use Dispatchable;
public function __construct(
protected Action $action,
) {}
public function getAction(): Action
{
return $this->action;
}
}
@@ -0,0 +1,7 @@
<?php
namespace Filament\Actions\Exceptions;
use Exception;
class ActionNotResolvableException extends Exception {}
@@ -0,0 +1,8 @@
<?php
namespace Filament\Actions;
class ExportAction extends Action
{
use Concerns\CanExportRecords;
}
@@ -0,0 +1,19 @@
<?php
namespace Filament\Actions;
use Filament\Actions\Concerns\CanExportRecords;
class ExportBulkAction extends BulkAction
{
use CanExportRecords {
setUp as baseSetUp;
}
protected function setUp(): void
{
$this->baseSetUp();
$this->fetchSelectedRecords(false);
}
}
@@ -0,0 +1,154 @@
<?php
namespace Filament\Actions\Exports\Concerns;
use BackedEnum;
use Closure;
use Illuminate\Support\Str;
trait CanFormatState
{
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 | Closure | null $prefix = null;
protected string | Closure | null $suffix = null;
protected bool $isListedAsJson = false;
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 | Closure | null $prefix): static
{
$this->prefix = $prefix;
return $this;
}
public function suffix(string | Closure | null $suffix): static
{
$this->suffix = $suffix;
return $this;
}
public function formatStateUsing(?Closure $callback): static
{
$this->formatStateUsing = $callback;
return $this;
}
public function formatState(mixed $state): mixed
{
$state = $this->evaluate($this->formatStateUsing ?? $state, [
'state' => $state,
]);
if ($state instanceof BackedEnum) {
$state = $state->value;
}
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 (filled($prefix)) {
$state = $prefix . $state;
}
if (filled($suffix)) {
$state .= $suffix;
}
return $state;
}
public function getFormattedState(): ?string
{
$state = $this->getState();
if (! is_array($state)) {
return $this->formatState($state);
}
$state = array_map($this->formatState(...), $state);
if ($this->isListedAsJson()) {
return json_encode($state);
}
return implode(', ', $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 getPrefix(): ?string
{
return $this->evaluate($this->prefix);
}
public function getSuffix(): ?string
{
return $this->evaluate($this->suffix);
}
public function listAsJson(bool $condition = true): static
{
$this->isListedAsJson = $condition;
return $this;
}
public function isListedAsJson(): bool
{
return $this->isListedAsJson;
}
}
@@ -0,0 +1,11 @@
<?php
namespace Filament\Actions\Exports\Downloaders\Contracts;
use Filament\Actions\Exports\Models\Export;
use Symfony\Component\HttpFoundation\StreamedResponse;
interface Downloader
{
public function __invoke(Export $export): StreamedResponse;
}
@@ -0,0 +1,42 @@
<?php
namespace Filament\Actions\Exports\Downloaders;
use Filament\Actions\Exports\Downloaders\Contracts\Downloader;
use Filament\Actions\Exports\Models\Export;
use Symfony\Component\HttpFoundation\StreamedResponse;
class CsvDownloader implements Downloader
{
public function __invoke(Export $export): StreamedResponse
{
$disk = $export->getFileDisk();
$directory = $export->getFileDirectory();
if (! $disk->exists($directory)) {
abort(404);
}
return response()->streamDownload(function () use ($disk, $directory): void {
echo $disk->get($directory . DIRECTORY_SEPARATOR . 'headers.csv');
flush();
foreach ($disk->files($directory) as $file) {
if (str($file)->endsWith('headers.csv')) {
continue;
}
if (! str($file)->endsWith('.csv')) {
continue;
}
echo $disk->get($file);
flush();
}
}, "{$export->file_name}.csv", [
'Content-Type' => 'text/csv',
]);
}
}
@@ -0,0 +1,74 @@
<?php
namespace Filament\Actions\Exports\Downloaders;
use Filament\Actions\Exports\Downloaders\Contracts\Downloader;
use Filament\Actions\Exports\Models\Export;
use League\Csv\Reader as CsvReader;
use League\Csv\Statement;
use OpenSpout\Common\Entity\Row;
use OpenSpout\Writer\XLSX\Writer;
use Symfony\Component\HttpFoundation\StreamedResponse;
class XlsxDownloader implements Downloader
{
public function __invoke(Export $export): StreamedResponse
{
$disk = $export->getFileDisk();
$directory = $export->getFileDirectory();
if (! $disk->exists($directory)) {
abort(404);
}
$fileName = $export->file_name . '.xlsx';
if ($disk->exists($filePath = $directory . DIRECTORY_SEPARATOR . $fileName)) {
$response = $disk->download($filePath);
if (ob_get_length() > 0) {
ob_end_clean();
}
$response->headers->set('X-Vapor-Base64-Encode', 'True');
return $response;
}
$writer = app(Writer::class);
$csvDelimiter = $export->exporter::getCsvDelimiter();
$writeRowsFromFile = function (string $file) use ($csvDelimiter, $disk, $writer): void {
$csvReader = CsvReader::createFromStream($disk->readStream($file));
$csvReader->setDelimiter($csvDelimiter);
$csvResults = (new Statement)->process($csvReader);
foreach ($csvResults->getRecords() as $row) {
$writer->addRow(Row::fromValues($row));
}
};
return response()->streamDownload(function () use ($disk, $directory, $fileName, $writer, $writeRowsFromFile): void {
$writer->openToBrowser($fileName);
$writeRowsFromFile($directory . DIRECTORY_SEPARATOR . 'headers.csv');
foreach ($disk->files($directory) as $file) {
if (str($file)->endsWith('headers.csv')) {
continue;
}
if (! str($file)->endsWith('.csv')) {
continue;
}
$writeRowsFromFile($file);
}
$writer->close();
}, $fileName, [
'Content-Type' => 'application/vnd.ms-excel',
]);
}
}
@@ -0,0 +1,14 @@
<?php
namespace Filament\Actions\Exports\Enums\Contracts;
use Filament\Actions\Action;
use Filament\Actions\Exports\Downloaders\Contracts\Downloader;
use Filament\Actions\Exports\Models\Export;
interface ExportFormat
{
public function getDownloader(): Downloader;
public function getDownloadNotificationAction(Export $export, string $authGuard): Action;
}
@@ -0,0 +1,34 @@
<?php
namespace Filament\Actions\Exports\Enums;
use Filament\Actions\Action;
use Filament\Actions\Exports\Downloaders\Contracts\Downloader;
use Filament\Actions\Exports\Downloaders\CsvDownloader;
use Filament\Actions\Exports\Downloaders\XlsxDownloader;
use Filament\Actions\Exports\Enums\Contracts\ExportFormat as ExportFormatInterface;
use Filament\Actions\Exports\Models\Export;
use Illuminate\Support\Facades\URL;
enum ExportFormat: string implements ExportFormatInterface
{
case Csv = 'csv';
case Xlsx = 'xlsx';
public function getDownloader(): Downloader
{
return match ($this) {
self::Csv => app(CsvDownloader::class),
self::Xlsx => app(XlsxDownloader::class),
};
}
public function getDownloadNotificationAction(Export $export, string $authGuard): Action
{
return Action::make("download_{$this->value}")
->label(__("filament-actions::export.notifications.completed.actions.download_{$this->value}.label"))
->url(URL::signedRoute('filament.exports.download', ['authGuard' => $authGuard, 'export' => $export, 'format' => $this], absolute: false), shouldOpenInNewTab: true)
->markAsRead();
}
}
@@ -0,0 +1,172 @@
<?php
namespace Filament\Actions\Exports;
use Closure;
use Filament\Support\Components\Component;
use Filament\Support\Concerns\CanAggregateRelatedModels;
use Filament\Support\Concerns\HasCellState;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use InvalidArgumentException;
class ExportColumn extends Component
{
use CanAggregateRelatedModels;
use Concerns\CanFormatState;
use HasCellState;
protected string $name;
protected string | Closure | null $label = null;
protected ?Exporter $exporter = null;
protected bool | Closure $isEnabledByDefault = true;
protected string $evaluationIdentifier = 'column';
final public function __construct(string $name)
{
$this->name($name);
}
public static function make(?string $name = null): static
{
$exportColumnClass = static::class;
$name ??= static::getDefaultName();
if (blank($name)) {
throw new InvalidArgumentException("Export column of class [$exportColumnClass] must have a unique name, passed to the [make()] method.");
}
$static = app($exportColumnClass, ['name' => $name]);
$static->configure();
return $static;
}
public static function getDefaultName(): ?string
{
return null;
}
public function name(string $name): static
{
$this->name = $name;
return $this;
}
public function label(string | Closure | null $label): static
{
$this->label = $label;
return $this;
}
public function exporter(?Exporter $exporter): static
{
$this->exporter = $exporter;
return $this;
}
public function enabledByDefault(bool | Closure $condition): static
{
$this->isEnabledByDefault = $condition;
return $this;
}
public function getName(): string
{
return $this->name;
}
public function getExporter(): ?Exporter
{
return $this->exporter;
}
public function isEnabledByDefault(): bool
{
return (bool) $this->evaluate($this->isEnabledByDefault);
}
public function getRecord(): ?Model
{
return $this->getExporter()?->getRecord();
}
public function getLabel(): ?string
{
return $this->evaluate($this->label) ?? (string) str($this->getName())
->beforeLast('.')
->afterLast('.')
->kebab()
->replace(['-', '_'], ' ')
->ucfirst();
}
public function applyRelationshipAggregates(EloquentBuilder $query): EloquentBuilder
{
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 $query): EloquentBuilder
{
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]);
}
protected function resolveDefaultClosureDependencyForEvaluationByName(string $parameterName): array
{
return match ($parameterName) {
'exporter' => [$this->getExporter()],
'options' => [$this->getExporter()->getOptions()],
'record' => [$this->getRecord()],
default => parent::resolveDefaultClosureDependencyForEvaluationByName($parameterName),
};
}
protected function resolveDefaultClosureDependencyForEvaluationByType(string $parameterType): array
{
$record = $this->getRecord();
return match ($parameterType) {
Exporter::class => [$this->getExporter()],
Model::class, $record ? $record::class : null => [$record],
default => parent::resolveDefaultClosureDependencyForEvaluationByType($parameterType),
};
}
}
@@ -0,0 +1,245 @@
<?php
namespace Filament\Actions\Exports;
use Carbon\CarbonInterface;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\Exports\Enums\Contracts\ExportFormat as ExportFormatInterface;
use Filament\Actions\Exports\Enums\ExportFormat;
use Filament\Actions\Exports\Models\Export;
use Filament\Schemas\Components\Component;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use OpenSpout\Common\Entity\Row;
use OpenSpout\Common\Entity\Style\Style;
use OpenSpout\Writer\XLSX\Options;
use OpenSpout\Writer\XLSX\Writer;
abstract class Exporter
{
/** @var array<ExportColumn> */
protected array $cachedColumns;
protected ?Model $record;
/**
* @var class-string<Model>|null
*/
protected static ?string $model = null;
/**
* @param array<string, string> $columnMap
* @param array<string, mixed> $options
*/
public function __construct(
protected Export $export,
protected array $columnMap,
protected array $options,
) {}
/**
* @return array<mixed>
*/
public function __invoke(Model $record): array
{
$this->record = $record;
$columns = $this->getCachedColumns();
$data = [];
foreach (array_keys($this->columnMap) as $column) {
$data[] = $columns[$column]->getFormattedState();
}
return $data;
}
/**
* @return array<ExportColumn>
*/
abstract public static function getColumns(): array;
/**
* @return array<Component | Action | ActionGroup>
*/
public static function getOptionsFormComponents(): array
{
return [];
}
/**
* @return class-string<Model>
*/
public static function getModel(): string
{
return static::$model ?? (string) str(class_basename(static::class))
->beforeLast('Exporter')
->prepend(app()->getNamespace() . 'Models\\');
}
abstract public static function getCompletedNotificationBody(Export $export): string;
public static function getCompletedNotificationTitle(Export $export): string
{
return __('filament-actions::export.notifications.completed.title');
}
/**
* @return array<int, object>
*/
public function getJobMiddleware(): array
{
return [
(new WithoutOverlapping("export{$this->export->getKey()}"))->expireAfter(600),
];
}
public function getJobRetryUntil(): ?CarbonInterface
{
return now()->addDay();
}
/**
* @return int | array<int> | null
*/
public function getJobBackoff(): int | array | null
{
return [60, 120, 300, 600];
}
/**
* @return array<int, string>
*/
public function getJobTags(): array
{
return ["export{$this->export->getKey()}"];
}
public function getJobQueue(): ?string
{
return null;
}
public function getJobConnection(): ?string
{
return null;
}
public function getJobBatchName(): ?string
{
return null;
}
/**
* @return array<ExportColumn>
*/
public function getCachedColumns(): array
{
return $this->cachedColumns ??= array_reduce(static::getColumns(), function (array $carry, ExportColumn $column): array {
$carry[$column->getName()] = $column->exporter($this);
return $carry;
}, []);
}
public function getRecord(): ?Model
{
return $this->record;
}
/**
* @return array<string, mixed>
*/
public function getOptions(): array
{
return $this->options;
}
public function getFileDisk(): string
{
$disk = config('filament.default_filesystem_disk');
if (($disk === 'public') && array_key_exists('local', config('filesystems.disks'))) {
return 'local';
}
return $disk;
}
public function getFileName(Export $export): string
{
return __('filament-actions::export.file_name', [
'export_id' => $export->getKey(),
'model' => (string) str(static::getModel())
->classBasename()
->beforeLast('Exporter')
->kebab()
->replace('-', ' ')
->plural()
->replace(' ', '-'),
]);
}
public static function getCsvDelimiter(): string
{
return ',';
}
/**
* @return array<ExportFormatInterface>
*/
public function getFormats(): array
{
return [ExportFormat::Csv, ExportFormat::Xlsx];
}
public function getXlsxCellStyle(): ?Style
{
return null;
}
public function getXlsxHeaderCellStyle(): ?Style
{
return null;
}
public function getXlsxWriterOptions(): ?Options
{
return null;
}
/**
* @param array<mixed> $values
*/
public function makeXlsxHeaderRow(array $values, ?Style $style = null): Row
{
return $this->makeXlsxRow($values, $style);
}
/**
* @param array<mixed> $values
*/
public function makeXlsxRow(array $values, ?Style $style = null): Row
{
return Row::fromValues($values, $style);
}
public function configureXlsxWriterBeforeClose(Writer $writer): Writer
{
return $writer;
}
/**
* @template TModel of Model
*
* @param Builder<TModel> $query
* @return Builder<TModel>
*/
public static function modifyQuery(Builder $query): Builder
{
return $query;
}
}
@@ -0,0 +1,48 @@
<?php
namespace Filament\Actions\Exports\Http\Controllers;
use Filament\Actions\Exports\Enums\Contracts\ExportFormat as ExportFormatInterface;
use Filament\Actions\Exports\Enums\ExportFormat;
use Filament\Actions\Exports\Models\Export;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\StreamedResponse;
class DownloadExport
{
public function __invoke(Request $request, Export $export): StreamedResponse
{
abort_unless(auth(
$request->hasValidSignature(absolute: false)
? $request->query('authGuard')
: null,
)->check(), 401);
$user = auth(
$request->hasValidSignature(absolute: false)
? $request->query('authGuard')
: null,
)->user();
$exportPolicy = Gate::getPolicyFor($export::class);
if (filled($exportPolicy) && method_exists($exportPolicy, 'view')) {
Gate::forUser($user)->authorize('view', Arr::wrap($export));
} else {
abort_unless($export->user()->is($user), 403);
}
$format = $this->resolveFormatFromRequest($request);
abort_unless($format !== null, 404);
return $format->getDownloader()($export);
}
protected function resolveFormatFromRequest(Request $request): ?ExportFormatInterface
{
return ExportFormat::tryFrom($request->query('format'));
}
}
@@ -0,0 +1,100 @@
<?php
namespace Filament\Actions\Exports\Jobs;
use Closure;
use Filament\Actions\Exports\Exporter;
use Filament\Actions\Exports\Models\Export;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\File;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use League\Csv\Reader as CsvReader;
use League\Csv\Statement;
use OpenSpout\Common\Entity\Style\Style;
use OpenSpout\Writer\XLSX\Writer;
class CreateXlsxFile implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public bool $deleteWhenMissingModels = true;
protected Exporter $exporter;
/**
* @param array<string, string> $columnMap
* @param array<string, mixed> $options
*/
public function __construct(
protected Export $export,
protected array $columnMap,
protected array $options = [],
) {
$this->exporter = $this->export->getExporter(
$this->columnMap,
$this->options,
);
}
public function handle(): void
{
$disk = $this->export->getFileDisk();
$writer = app(Writer::class, ['options' => $this->exporter->getXlsxWriterOptions()]);
$writer->openToFile($temporaryFile = tempnam(sys_get_temp_dir(), $this->export->file_name));
$csvDelimiter = $this->exporter::getCsvDelimiter();
$writeRowsFromFile = function (string $file, ?Style $style, ?Closure $makeRow) use ($csvDelimiter, $disk, $writer): void {
$csvReader = CsvReader::from($disk->readStream($file));
$csvReader->setDelimiter($csvDelimiter);
$csvResults = (new Statement)->process($csvReader);
foreach ($csvResults->getRecords() as $values) {
$writer->addRow($makeRow($values, $style));
}
};
$cellStyle = $this->exporter->getXlsxCellStyle();
$writeRowsFromFile(
$this->export->getFileDirectory() . DIRECTORY_SEPARATOR . 'headers.csv',
$this->exporter->getXlsxHeaderCellStyle() ?? $cellStyle,
$this->exporter->makeXlsxHeaderRow(...),
);
$makeRow = $this->exporter->makeXlsxRow(...);
foreach ($disk->files($this->export->getFileDirectory()) as $file) {
if (str($file)->endsWith('headers.csv')) {
continue;
}
if (! str($file)->endsWith('.csv')) {
continue;
}
$writeRowsFromFile($file, $cellStyle, $makeRow);
}
$this->exporter->configureXlsxWriterBeforeClose($writer);
$writer->close();
$disk->putFileAs(
$this->export->getFileDirectory(),
new File($temporaryFile),
"{$this->export->file_name}.xlsx",
Filesystem::VISIBILITY_PRIVATE,
);
unlink($temporaryFile);
}
}
@@ -0,0 +1,87 @@
<?php
namespace Filament\Actions\Exports\Jobs;
use Filament\Actions\Action;
use Filament\Actions\Exports\Enums\Contracts\ExportFormat;
use Filament\Actions\Exports\Exporter;
use Filament\Actions\Exports\Models\Export;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ExportCompletion implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public bool $deleteWhenMissingModels = true;
protected Exporter $exporter;
/**
* @param array<string, string> $columnMap
* @param array<ExportFormat> $formats
* @param array<string, mixed> $options
*/
public function __construct(
protected Export $export,
protected array $columnMap,
protected array $formats,
protected array $options,
protected string $authGuard,
) {
$this->exporter = $this->export->getExporter(
$this->columnMap,
$this->options,
);
}
public function handle(): void
{
$this->export->touch('completed_at');
if (! $this->export->user instanceof Authenticatable) { /** @phpstan-ignore instanceof.alwaysTrue */
return;
}
$failedRowsCount = $this->export->getFailedRowsCount();
Notification::make()
->title($this->exporter::getCompletedNotificationTitle($this->export))
->body($this->exporter::getCompletedNotificationBody($this->export))
->when(
! $failedRowsCount,
fn (Notification $notification) => $notification->success(),
)
->when(
$failedRowsCount && ($failedRowsCount < $this->export->total_rows),
fn (Notification $notification) => $notification->warning(),
)
->when(
$failedRowsCount === $this->export->total_rows,
fn (Notification $notification) => $notification->danger(),
)
->when(
$failedRowsCount < $this->export->total_rows,
fn (Notification $notification) => $notification->actions(array_map(
fn (ExportFormat $format): Action => $format->getDownloadNotificationAction($this->export, $this->authGuard),
$this->formats,
)),
)
->when(
($this->connection === 'sync') ||
(blank($this->connection) && (config('queue.default') === 'sync')),
fn (Notification $notification) => $notification
->persistent()
->send(),
fn (Notification $notification) => $notification->sendToDatabase($this->export->user, isEventDispatched: true),
);
}
}
@@ -0,0 +1,147 @@
<?php
namespace Filament\Actions\Exports\Jobs;
use AnourValar\EloquentSerialize\Facades\EloquentSerializeFacade;
use Carbon\CarbonInterface;
use Filament\Actions\Exports\Exporter;
use Filament\Actions\Exports\Models\Export;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Query\Expression;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use League\Csv\Writer;
use SplTempFileObject;
use Throwable;
class ExportCsv implements ShouldQueue
{
use Batchable;
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public bool $deleteWhenMissingModels = true;
protected Exporter $exporter;
public ?int $maxExceptions = 5;
/**
* @param array<mixed> $records
* @param array<string, string> $columnMap
* @param array<string, mixed> $options
*/
public function __construct(
protected Export $export,
protected string $query,
protected array $records,
protected int $page,
protected array $columnMap,
protected array $options = [],
) {
$this->exporter = $this->export->getExporter(
$this->columnMap,
$this->options,
);
}
/**
* @return array<int, object>
*/
public function middleware(): array
{
return $this->exporter->getJobMiddleware();
}
public function handle(): void
{
/** @var Authenticatable $user */
$user = $this->export->user;
auth()->setUser($user);
$processedRows = 0;
$successfulRows = 0;
$csv = Writer::from(new SplTempFileObject);
$csv->setDelimiter($this->exporter::getCsvDelimiter());
$query = EloquentSerializeFacade::unserialize($this->query);
foreach ($this->exporter->getCachedColumns() as $column) {
$column->applyRelationshipAggregates($query);
$column->applyEagerLoading($query);
}
foreach ($query->find($this->records) as $record) {
try {
$csv->insertOne(($this->exporter)($record));
$successfulRows++;
} catch (Throwable $exception) {
report($exception);
}
$processedRows++;
}
$filePath = $this->export->getFileDirectory() . DIRECTORY_SEPARATOR . str_pad(strval($this->page), 16, '0', STR_PAD_LEFT) . '.csv';
DB::transaction(function () use ($csv, $filePath, $processedRows, $successfulRows): void {
$this->export::query()
->whereKey($this->export->getKey())
->lockForUpdate()
->update([
'processed_rows' => new Expression('processed_rows + ' . $processedRows),
'successful_rows' => new Expression('successful_rows + ' . $successfulRows),
]);
$this->export::query()
->whereKey($this->export->getKey())
->whereColumn('processed_rows', '>', 'total_rows')
->lockForUpdate()
->update([
'processed_rows' => new Expression('total_rows'),
]);
$this->export::query()
->whereKey($this->export->getKey())
->whereColumn('successful_rows', '>', 'total_rows')
->lockForUpdate()
->update([
'successful_rows' => new Expression('total_rows'),
]);
$this->export->getFileDisk()->put($filePath, $csv->toString(), Filesystem::VISIBILITY_PRIVATE);
});
}
public function retryUntil(): ?CarbonInterface
{
return $this->exporter->getJobRetryUntil();
}
/**
* @return int | array<int> | null
*/
public function backoff(): int | array | null
{
return $this->exporter->getJobBackoff();
}
/**
* @return array<int, string>
*/
public function tags(): array
{
return $this->exporter->getJobTags();
}
}
@@ -0,0 +1,220 @@
<?php
namespace Filament\Actions\Exports\Jobs;
use AnourValar\EloquentSerialize\Facades\EloquentSerializeFacade;
use Filament\Actions\Exports\Exporter;
use Filament\Actions\Exports\Models\Export;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Connection;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use League\Csv\Bom;
use League\Csv\Writer;
use SplTempFileObject;
class PrepareCsvExport implements ShouldQueue
{
use Batchable;
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public bool $deleteWhenMissingModels = true;
protected Exporter $exporter;
/**
* @param array<string, string> $columnMap
* @param array<string, mixed> $options
* @param array<mixed> | null $records
*/
public function __construct(
protected Export $export,
protected string $query,
protected array $columnMap,
protected array $options = [],
protected int $chunkSize = 100,
protected ?array $records = null,
) {
$this->exporter = $this->export->getExporter(
$this->columnMap,
$this->options,
);
}
public function handle(): void
{
$csv = Writer::from(new SplTempFileObject);
$csv->setOutputBOM(Bom::Utf8);
$csv->setDelimiter($this->exporter::getCsvDelimiter());
$csv->insertOne(array_values($this->columnMap));
$filePath = $this->export->getFileDirectory() . DIRECTORY_SEPARATOR . 'headers.csv';
$this->export->getFileDisk()->put($filePath, $csv->toString(), Filesystem::VISIBILITY_PRIVATE);
$query = EloquentSerializeFacade::unserialize($this->query);
$keyName = $query->getModel()->getKeyName();
$qualifiedKeyName = $query->getModel()->getQualifiedKeyName();
/** @var Connection $databaseConnection */
$databaseConnection = $query->getConnection();
$databaseGrammar = $query->getGrammar();
if ($databaseConnection->getDriverName() === 'pgsql') {
$originalOrders = collect($query->getQuery()->orders)
->reject(function (array $order) use ($qualifiedKeyName): bool {
if (($order['type'] ?? null) === 'Raw') {
return false;
}
return ($order['column'] ?? null) === $qualifiedKeyName;
})
->unique(function (array $order) use ($databaseGrammar): string {
if (($order['type'] ?? null) === 'Raw') {
return 'raw:' . ($order['sql'] ?? '');
}
if ($databaseGrammar->isExpression($order['column'] ?? null)) {
return 'expression:' . $order['column']->getValue($databaseGrammar);
}
return 'column:' . ($order['column'] ?? '');
});
/** @var array<string, mixed> $originalBindings */
$originalBindings = $query->getRawBindings();
if (! empty($originalOrders->all())) {
$firstOrder = $originalOrders->first();
if (($firstOrder['type'] ?? null) === 'Raw') {
$query->reorder();
$query->orderByRaw($firstOrder['sql']);
} else {
$query->reorder($firstOrder['column'], $firstOrder['direction']);
}
$originalOrders->forget(0);
} else {
$query->reorder($qualifiedKeyName);
}
foreach ($originalOrders as $order) {
if (($order['type'] ?? null) === 'Raw') {
$query->orderByRaw($order['sql']);
} elseif (filled($order['column'] ?? null) && filled($order['direction'] ?? null)) {
$query->orderBy($order['column'], $order['direction']);
}
}
$newBindings = $query->getRawBindings();
foreach ($originalBindings as $key => $value) {
if ($binding = array_diff($value, $newBindings[$key])) {
$query->addBinding($binding, $key);
}
}
}
$exportCsvJob = $this->getExportCsvJob();
$totalRows = 0;
$page = 1;
// We do not want to send the loaded user relationship to the queue in job payloads,
// in case it contains attributes that are not serializable, such as binary columns.
$this->export->unsetRelation('user');
$dispatchRecords = function (array $records) use ($exportCsvJob, &$page, &$totalRows): void {
$recordsCount = count($records);
if (($totalRows + $recordsCount) > $this->export->total_rows) {
$records = array_slice($records, 0, $this->export->total_rows - $totalRows);
$recordsCount = count($records);
}
if (! $recordsCount) {
return;
}
$jobs = [];
foreach (array_chunk($records, length: $this->chunkSize) as $recordsChunk) {
$jobs[] = app($exportCsvJob, [
'export' => $this->export,
'query' => $this->query,
'records' => $recordsChunk,
'page' => $page,
'columnMap' => $this->columnMap,
'options' => $this->options,
]);
$page++;
}
$this->batch()->add($jobs);
$totalRows += $recordsCount;
};
if ($this->records !== null) {
$dispatchRecords($this->records);
return;
}
$chunkKeySize = $this->chunkSize * 10;
$baseQuery = $query->toBase();
if (in_array($query->getQuery()->orders[0]['column'] ?? null, [$keyName, $qualifiedKeyName])) {
$baseQuery->distinct($qualifiedKeyName);
}
/** @phpstan-ignore-next-line */
$baseQueryOrders = $baseQuery->orders ?? [];
$baseQueryOrdersCount = count($baseQueryOrders);
if (
(
($baseQueryOrdersCount === 1) &&
(! in_array($baseQueryOrders[0]['column'] ?? null, [$keyName, $qualifiedKeyName]))
) ||
($baseQueryOrdersCount > 1)
) {
$baseQuery->chunk(
$this->chunkSize,
fn (Collection $records) => $dispatchRecords(
Arr::pluck($records->all(), $keyName),
),
);
return;
}
$baseQuery
->select([$qualifiedKeyName])
->orderedChunkById(
$chunkKeySize,
fn (Collection $records) => $dispatchRecords(
Arr::pluck($records->all(), $keyName),
),
column: $qualifiedKeyName,
alias: $keyName,
descending: ($baseQueryOrders[0]['direction'] ?? 'asc') === 'desc',
);
}
public function getExportCsvJob(): string
{
return ExportCsv::class;
}
}
@@ -0,0 +1,119 @@
<?php
namespace Filament\Actions\Exports\Models;
use Carbon\CarbonInterface;
use Filament\Actions\Exports\Exporter;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Prunable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
use LogicException;
/**
* @property CarbonInterface | null $completed_at
* @property string $file_disk
* @property string $file_name
* @property class-string<Exporter> $exporter
* @property int $processed_rows
* @property int $total_rows
* @property int $successful_rows
* @property-read Authenticatable $user
*/
class Export extends Model
{
use Prunable;
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'completed_at' => 'timestamp',
'processed_rows' => 'integer',
'total_rows' => 'integer',
'successful_rows' => 'integer',
];
}
protected $guarded = [];
protected static bool $hasPolymorphicUserRelationship = false;
public function user(): BelongsTo
{
if (static::hasPolymorphicUserRelationship()) {
return $this->morphTo();
}
/** @var ?Authenticatable $authenticatable */
$authenticatable = app(Authenticatable::class);
if ($authenticatable) {
/** @phpstan-ignore-next-line */
return $this->belongsTo($authenticatable::class);
}
$userClass = app()->getNamespace() . 'Models\\User';
if (! class_exists($userClass)) {
throw new LogicException('No [' . $userClass . '] model found. Please bind an authenticatable model to the [Illuminate\\Contracts\\Auth\\Authenticatable] interface in a service provider\'s [register()] method.');
}
/** @phpstan-ignore-next-line */
return $this->belongsTo($userClass);
}
/**
* @param array<string, string> $columnMap
* @param array<string, mixed> $options
*/
public function getExporter(
array $columnMap,
array $options,
): Exporter {
return app($this->exporter, [
'export' => $this,
'columnMap' => $columnMap,
'options' => $options,
]);
}
public function getFailedRowsCount(): int
{
return $this->total_rows - $this->successful_rows;
}
public static function polymorphicUserRelationship(bool $condition = true): void
{
static::$hasPolymorphicUserRelationship = $condition;
}
public static function hasPolymorphicUserRelationship(): bool
{
return static::$hasPolymorphicUserRelationship;
}
public function getFileDisk(): Filesystem
{
return Storage::disk($this->file_disk);
}
public function getFileDirectory(): string
{
return 'filament_exports' . DIRECTORY_SEPARATOR . $this->getKey();
}
public function deleteFileDirectory(): void
{
$disk = $this->getFileDisk();
$directory = $this->getFileDirectory();
if ($disk->directoryExists($directory)) {
$disk->deleteDirectory($directory);
}
}
}
@@ -0,0 +1,59 @@
<?php
namespace Filament\Actions;
use Filament\Actions\Concerns\CanCustomizeProcess;
use Filament\Actions\View\ActionsIconAlias;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
use Illuminate\Database\Eloquent\Model;
class ForceDeleteAction extends Action
{
use CanCustomizeProcess;
public static function getDefaultName(): ?string
{
return 'forceDelete';
}
protected function setUp(): void
{
parent::setUp();
$this->label(__('filament-actions::force-delete.single.label'));
$this->modalHeading(fn (): string => __('filament-actions::force-delete.single.modal.heading', ['label' => $this->getRecordTitle()]));
$this->modalSubmitActionLabel(__('filament-actions::force-delete.single.modal.actions.delete.label'));
$this->defaultColor('danger');
$this->tableIcon(FilamentIcon::resolve(ActionsIconAlias::FORCE_DELETE_ACTION) ?? Heroicon::Trash);
$this->groupedIcon(FilamentIcon::resolve(ActionsIconAlias::FORCE_DELETE_ACTION_GROUPED) ?? Heroicon::Trash);
$this->requiresConfirmation();
$this->modalIcon(FilamentIcon::resolve(ActionsIconAlias::FORCE_DELETE_ACTION_MODAL) ?? Heroicon::OutlinedTrash);
$this->action(function (): void {
$result = $this->process(static fn (Model $record): ?bool => $record->forceDelete());
if (! $result) {
$this->failure();
return;
}
$this->success();
});
$this->visible(static function (Model $record): bool {
if (! method_exists($record, 'trashed')) {
return false;
}
return $record->trashed();
});
}
}
@@ -0,0 +1,130 @@
<?php
namespace Filament\Actions;
use Filament\Actions\Concerns\CanCustomizeProcess;
use Filament\Actions\View\ActionsIconAlias;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\TrashedFilter;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\LazyCollection;
use Illuminate\Support\Number;
use Throwable;
class ForceDeleteBulkAction extends BulkAction
{
use CanCustomizeProcess;
public static function getDefaultName(): ?string
{
return 'forceDelete';
}
protected function setUp(): void
{
parent::setUp();
$this->label(__('filament-actions::force-delete.multiple.label'));
$this->modalHeading(fn (): string => __('filament-actions::force-delete.multiple.modal.heading', ['label' => $this->getTitleCasePluralModelLabel()]));
$this->modalSubmitActionLabel(__('filament-actions::force-delete.multiple.modal.actions.delete.label'));
$this->successNotificationTitle(__('filament-actions::force-delete.multiple.notifications.deleted.title'));
$this->failureNotificationTitle(function (int $successCount, int $totalCount): string {
if ($successCount) {
return trans_choice('filament-actions::force-delete.multiple.notifications.deleted_partial.title', $successCount, [
'count' => Number::format($successCount),
'total' => Number::format($totalCount),
]);
}
return trans_choice('filament-actions::force-delete.multiple.notifications.deleted_none.title', $totalCount, [
'count' => Number::format($totalCount),
'total' => Number::format($totalCount),
]);
});
$this->missingBulkAuthorizationFailureNotificationMessage(function (int $failureCount, int $totalCount): string {
return trans_choice(
($failureCount === $totalCount)
? 'filament-actions::force-delete.multiple.notifications.deleted_none.missing_authorization_failure_message'
: 'filament-actions::force-delete.multiple.notifications.deleted_partial.missing_authorization_failure_message',
$failureCount,
['count' => Number::format($failureCount)],
);
});
$this->missingBulkProcessingFailureNotificationMessage(function (int $failureCount, int $totalCount): string {
return trans_choice(
($failureCount === $totalCount)
? 'filament-actions::force-delete.multiple.notifications.deleted_none.missing_processing_failure_message'
: 'filament-actions::force-delete.multiple.notifications.deleted_partial.missing_processing_failure_message',
$failureCount,
['count' => Number::format($failureCount)],
);
});
$this->defaultColor('danger');
$this->icon(FilamentIcon::resolve(ActionsIconAlias::FORCE_DELETE_ACTION) ?? Heroicon::Trash);
$this->requiresConfirmation();
$this->modalIcon(FilamentIcon::resolve(ActionsIconAlias::FORCE_DELETE_ACTION_MODAL) ?? Heroicon::OutlinedTrash);
$this->action(function (): void {
$this->process(static function (ForceDeleteBulkAction $action, EloquentCollection | Collection | LazyCollection $records): void {
if (! $action->shouldFetchSelectedRecords()) {
try {
$action->reportBulkProcessingSuccessfulRecordsCount(
$action->getSelectedRecordsQuery()->forceDelete(),
);
} catch (Throwable $exception) {
$action->reportCompleteBulkProcessingFailure();
report($exception);
}
return;
}
$isFirstException = true;
$records->each(static function (Model $record) use ($action, &$isFirstException): void {
try {
$record->forceDelete() || $action->reportBulkProcessingFailure();
} catch (Throwable $exception) {
$action->reportBulkProcessingFailure();
if ($isFirstException) {
// Only report the first exception so as to not flood error logs. Even
// if Filament did not catch exceptions like this, only the first
// would be reported as the rest of the process would be halted.
report($exception);
$isFirstException = false;
}
}
});
});
});
$this->deselectRecordsAfterCompletion();
$this->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
if (! array_key_exists('value', $trashedFilterState)) {
return false;
}
return blank($trashedFilterState['value']);
});
}
}
@@ -0,0 +1,17 @@
<?php
namespace Filament\Actions;
/**
* @deprecated Use `\Filament\Actions\Action` instead, with the `iconButton()` method.
* @see Action
*/
class IconButtonAction extends Action
{
protected function setUp(): void
{
parent::setUp();
$this->iconButton();
}
}
@@ -0,0 +1,706 @@
<?php
namespace Filament\Actions;
use Closure;
use Filament\Actions\Imports\Events\ImportCompleted;
use Filament\Actions\Imports\Events\ImportStarted;
use Filament\Actions\Imports\ImportColumn;
use Filament\Actions\Imports\Importer;
use Filament\Actions\Imports\Jobs\ImportCsv;
use Filament\Actions\Imports\Models\Import;
use Filament\Actions\View\ActionsIconAlias;
use Filament\Facades\Filament;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Fieldset;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Support\ChunkIterator;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
use Illuminate\Bus\PendingBatch;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Filesystem\AwsS3V3Adapter;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Number;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use League\Csv\Bom;
use League\Csv\CharsetConverter;
use League\Csv\Info;
use League\Csv\Reader as CsvReader;
use League\Csv\Statement;
use League\Csv\Writer;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use SplTempFileObject;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ImportAction extends Action
{
/**
* @var class-string<Importer>
*/
protected string $importer;
protected ?string $job = null;
protected int | Closure $chunkSize = 100;
protected int | Closure | null $maxRows = null;
protected int | Closure | null $headerOffset = null;
protected string | Closure | null $csvDelimiter = null;
/**
* @var array<string, mixed> | Closure
*/
protected array | Closure $options = [];
/**
* @var array<string | array<mixed> | Closure>
*/
protected array $fileValidationRules = [];
protected string | Closure | null $authGuard = null;
protected function setUp(): void
{
parent::setUp();
$this->label(fn (ImportAction $action): string => __('filament-actions::import.label', ['label' => $action->getPluralModelLabel()]));
$this->modalHeading(fn (ImportAction $action): string => __('filament-actions::import.modal.heading', ['label' => $action->getTitleCasePluralModelLabel()]));
$this->modalDescription(fn (ImportAction $action): Htmlable => $action->getModalAction('downloadExample'));
$this->modalSubmitActionLabel(__('filament-actions::import.modal.actions.import.label'));
$this->groupedIcon(FilamentIcon::resolve(ActionsIconAlias::IMPORT_ACTION_GROUPED) ?? Heroicon::ArrowUpTray);
$this->schema(fn (ImportAction $action): array => array_merge([
FileUpload::make('file')
->label(__('filament-actions::import.modal.form.file.label'))
->placeholder(__('filament-actions::import.modal.form.file.placeholder'))
->acceptedFileTypes(['text/csv', 'text/x-csv', 'application/csv', 'application/x-csv', 'text/comma-separated-values', 'text/x-comma-separated-values', 'text/plain', 'application/vnd.ms-excel'])
->rules($action->getFileValidationRules())
->afterStateUpdated(function (FileUpload $component, Component $livewire, Set $set, ?TemporaryUploadedFile $state) use ($action): void {
if (! $state instanceof TemporaryUploadedFile) {
return;
}
try {
$livewire->validateOnly($component->getStatePath());
} catch (ValidationException $exception) {
$component->state([]);
throw $exception;
}
$csvStream = $this->getUploadedFileStream($state);
if (! $csvStream) {
return;
}
$csvReader = CsvReader::createFromStream($csvStream);
if (filled($csvDelimiter = $this->getCsvDelimiter($csvReader))) {
$csvReader->setDelimiter($csvDelimiter);
}
$csvReader->setHeaderOffset($action->getHeaderOffset() ?? 0);
$csvColumns = $csvReader->getHeader();
$lowercaseCsvColumnValues = array_map(Str::lower(...), $csvColumns);
$lowercaseCsvColumnKeys = array_combine(
$lowercaseCsvColumnValues,
$csvColumns,
);
$set('columnMap', array_reduce($action->getImporter()::getColumns(), function (array $carry, ImportColumn $column) use ($lowercaseCsvColumnKeys, $lowercaseCsvColumnValues) {
$carry[$column->getName()] = $lowercaseCsvColumnKeys[
Arr::first(
array_intersect(
$lowercaseCsvColumnValues,
$column->getGuesses(),
),
)
] ?? null;
return $carry;
}, []));
})
->storeFiles(false)
->visibility('private')
->required()
->hiddenLabel(),
Fieldset::make(__('filament-actions::import.modal.form.columns.label'))
->columns(1)
->inlineLabel()
->schema(function (Get $get) use ($action): array {
$csvFile = $get('file');
if (! $csvFile instanceof TemporaryUploadedFile) {
return [];
}
$csvStream = $this->getUploadedFileStream($csvFile);
if (! $csvStream) {
return [];
}
$csvReader = CsvReader::createFromStream($csvStream);
if (filled($csvDelimiter = $this->getCsvDelimiter($csvReader))) {
$csvReader->setDelimiter($csvDelimiter);
}
$csvReader->setHeaderOffset($action->getHeaderOffset() ?? 0);
$csvColumns = $csvReader->getHeader();
$csvColumnOptions = array_combine($csvColumns, $csvColumns);
return array_map(
fn (ImportColumn $column): Select => $column->getSelect()->options($csvColumnOptions),
$action->getImporter()::getColumns(),
);
})
->statePath('columnMap')
->visible(fn (Get $get): bool => $get('file') instanceof TemporaryUploadedFile),
], $action->getImporter()::getOptionsFormComponents()));
$this->action(function (ImportAction $action, array $data): void {
/** @var TemporaryUploadedFile $csvFile */
$csvFile = $data['file'];
$csvStream = $this->getUploadedFileStream($csvFile);
if (! $csvStream) {
return;
}
$csvReader = CsvReader::createFromStream($csvStream);
if (filled($csvDelimiter = $this->getCsvDelimiter($csvReader))) {
$csvReader->setDelimiter($csvDelimiter);
}
$csvReader->setHeaderOffset($action->getHeaderOffset() ?? 0);
$csvResults = (new Statement)->process($csvReader);
$totalRows = $csvResults->count();
$maxRows = $action->getMaxRows() ?? $totalRows;
if ($maxRows < $totalRows) {
$action->failureNotification(
Notification::make()
->title(__('filament-actions::import.notifications.max_rows.title'))
->body(trans_choice('filament-actions::import.notifications.max_rows.body', $maxRows, [
'count' => Number::format($maxRows),
]))
->danger(),
);
$action->failure();
return;
}
$authGuard = $action->getAuthGuard();
$user = auth($authGuard)->user();
$import = app(Import::class);
$import->user()->associate($user);
$import->file_name = $csvFile->getClientOriginalName();
$import->file_path = $csvFile->getRealPath();
$import->importer = $action->getImporter();
$import->total_rows = $totalRows;
$import->save();
$importChunkIterator = new ChunkIterator($csvResults->getRecords(), chunkSize: $action->getChunkSize());
/** @var array<array<array<string, string>>> $importChunks */
$importChunks = $importChunkIterator->get(); /** @phpstan-ignore varTag.nativeType */
$job = $action->getJob();
$options = array_merge(
$action->getOptions(),
Arr::except($data, ['file', 'columnMap']),
);
// We do not want to send the loaded user relationship to the queue in job payloads,
// in case it contains attributes that are not serializable, such as binary columns.
$import->unsetRelation('user');
$importJobs = collect($importChunks)
->map(fn (array $importChunk): object => app($job, [
'import' => $import,
'rows' => base64_encode(serialize($importChunk)),
'columnMap' => $data['columnMap'],
'options' => $options,
]));
$columnMap = $data['columnMap'];
$importer = $import->getImporter(
columnMap: $columnMap,
options: $options,
);
event(new ImportStarted($import, $columnMap, $options));
Bus::batch($importJobs->all())
->allowFailures()
->when(
filled($jobQueue = $importer->getJobQueue()),
fn (PendingBatch $batch) => $batch->onQueue($jobQueue),
)
->when(
filled($jobConnection = $importer->getJobConnection()),
fn (PendingBatch $batch) => $batch->onConnection($jobConnection),
)
->when(
filled($jobBatchName = $importer->getJobBatchName()),
fn (PendingBatch $batch) => $batch->name($jobBatchName),
)
->finally(function () use ($authGuard, $columnMap, $import, $jobConnection, $options): void {
$import->touch('completed_at');
event(new ImportCompleted($import, $columnMap, $options));
if (! $import->user instanceof Authenticatable) { /** @phpstan-ignore instanceof.alwaysTrue */
return;
}
$failedRowsCount = $import->getFailedRowsCount();
Notification::make()
->title($import->importer::getCompletedNotificationTitle($import))
->body($import->importer::getCompletedNotificationBody($import))
->when(
! $failedRowsCount,
fn (Notification $notification) => $notification->success(),
)
->when(
$failedRowsCount && ($failedRowsCount < $import->total_rows),
fn (Notification $notification) => $notification->warning(),
)
->when(
$failedRowsCount === $import->total_rows,
fn (Notification $notification) => $notification->danger(),
)
->when(
$failedRowsCount,
fn (Notification $notification) => $notification->actions([
Action::make('downloadFailedRowsCsv')
->label(trans_choice('filament-actions::import.notifications.completed.actions.download_failed_rows_csv.label', $failedRowsCount, [
'count' => Number::format($failedRowsCount),
]))
->color('danger')
->url(URL::signedRoute('filament.imports.failed-rows.download', ['authGuard' => $authGuard, 'import' => $import], absolute: false), shouldOpenInNewTab: true)
->markAsRead(),
]),
)
->when(
($jobConnection === 'sync') ||
(blank($jobConnection) && (config('queue.default') === 'sync')),
fn (Notification $notification) => $notification
->persistent()
->send(),
fn (Notification $notification) => $notification->sendToDatabase($import->user, isEventDispatched: true),
);
})
->dispatch();
if (
($jobConnection === 'sync')
|| (blank($jobConnection) && (config('queue.default') === 'sync'))
) {
$action->successNotification(null);
$action->successNotificationTitle(null);
return;
}
$action->successNotification(
Notification::make()
->title($action->getSuccessNotificationTitle())
->body(trans_choice('filament-actions::import.notifications.started.body', $import->total_rows, [
'count' => Number::format($import->total_rows),
]))
->success(),
);
});
$this->registerModalActions([
Action::make('downloadExample')
->label(__('filament-actions::import.modal.actions.download_example.label'))
->link()
->action(function (): StreamedResponse {
$columns = $this->getImporter()::getColumns();
$csv = Writer::createFromFileObject(new SplTempFileObject);
if (filled($csvDelimiter = $this->getCsvDelimiter())) {
$csv->setDelimiter($csvDelimiter);
}
$csv->insertOne(array_map(
fn (ImportColumn $column): string => $column->getExampleHeader(),
$columns,
));
$columnExamples = array_map(
fn (ImportColumn $column): array => $column->getExamples(),
$columns,
);
$exampleRowsCount = array_reduce(
$columnExamples,
fn (int $count, array $exampleData): int => max($count, count($exampleData)),
initial: 0,
);
$exampleRows = [];
foreach ($columnExamples as $exampleData) {
for ($i = 0; $i < $exampleRowsCount; $i++) {
$exampleRows[$i][] = $exampleData[$i] ?? '';
}
}
$csv->insertAll($exampleRows);
return response()->streamDownload(function () use ($csv): void {
$csv->setOutputBOM(Bom::Utf8);
echo $csv->toString();
}, __('filament-actions::import.example_csv.file_name', ['importer' => (string) str($this->getImporter())->classBasename()->kebab()]), [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}),
]);
$this->defaultColor('gray');
$this->modalWidth('xl');
$this->successNotificationTitle(__('filament-actions::import.notifications.started.title'));
$this->model(fn (ImportAction $action): string => $action->getImporter()::getModel());
}
/**
* @return resource | false
*/
public function getUploadedFileStream(TemporaryUploadedFile $file)
{
$fileDisk = invade($file)->disk; /** @phpstan-ignore-line */
if (config("filesystems.disks.{$fileDisk}.driver") !== 's3') {
$resource = $file->readStream();
} else {
/** @var AwsS3V3Adapter $s3Adapter */
$s3Adapter = Storage::disk($fileDisk)->getAdapter();
invade($s3Adapter)->client->registerStreamWrapper(); /** @phpstan-ignore-line */
$fileS3Path = (string) str('s3://' . config("filesystems.disks.{$fileDisk}.bucket") . '/' . $file->getRealPath())->replace('\\', '/');
$resource = fopen($fileS3Path, mode: 'r', context: stream_context_create([
's3' => [
'seekable' => true,
],
]));
}
$inputEncoding = $this->detectCsvEncoding($resource);
$outputEncoding = 'UTF-8';
if (
filled($inputEncoding) &&
(Str::lower($inputEncoding) !== Str::lower($outputEncoding))
) {
CharsetConverter::register();
stream_filter_append(
$resource,
CharsetConverter::getFiltername($inputEncoding, $outputEncoding),
STREAM_FILTER_READ,
);
}
return $resource;
}
protected function detectCsvEncoding(mixed $resource): ?string
{
rewind($resource);
$lineCount = 0;
$contentSample = '';
while ((! feof($resource)) && ($lineCount < 20)) {
$line = fgets($resource);
if ($line === false) {
break;
}
$contentSample .= $line;
$lineCount++;
}
// The encoding of a subset should be declared before the encoding of its superset.
$encodings = [
'UTF-8',
'SJIS-win',
'EUC-KR',
'ISO-8859-1',
'GB18030',
'Windows-1251',
'Windows-1252',
'EUC-JP',
];
foreach ($encodings as $encoding) {
if (! mb_check_encoding($contentSample, $encoding)) {
continue;
}
return $encoding;
}
return null;
}
public static function getDefaultName(): ?string
{
return 'import';
}
/**
* @param class-string<Importer> $importer
*/
public function importer(string $importer): static
{
$this->importer = $importer;
return $this;
}
/**
* @param class-string | null $job
*/
public function job(?string $job): static
{
$this->job = $job;
return $this;
}
public function chunkSize(int | Closure $size): static
{
$this->chunkSize = $size;
return $this;
}
public function maxRows(int | Closure | null $rows): static
{
$this->maxRows = $rows;
return $this;
}
public function headerOffset(int | Closure | null $offset): static
{
$this->headerOffset = $offset;
return $this;
}
public function csvDelimiter(string | Closure | null $delimiter): static
{
$this->csvDelimiter = $delimiter;
return $this;
}
/**
* @return class-string<Importer>
*/
public function getImporter(): string
{
return $this->importer;
}
/**
* @return class-string
*/
public function getJob(): string
{
return $this->job ?? ImportCsv::class;
}
public function getChunkSize(): int
{
return $this->evaluate($this->chunkSize);
}
public function getMaxRows(): ?int
{
return $this->evaluate($this->maxRows);
}
public function getHeaderOffset(): ?int
{
return $this->evaluate($this->headerOffset);
}
public function getCsvDelimiter(?CsvReader $reader = null): ?string
{
return $this->evaluate($this->csvDelimiter) ?? $this->guessCsvDelimiter($reader);
}
protected function guessCsvDelimiter(?CsvReader $reader = null): ?string
{
if (! $reader) {
return null;
}
$delimiterCounts = Info::getDelimiterStats($reader, delimiters: [',', ';', '|', "\t"], limit: 10);
return array_search(max($delimiterCounts), $delimiterCounts);
}
/**
* @param array<string, mixed> | Closure $options
*/
public function options(array | Closure $options): static
{
$this->options = $options;
return $this;
}
/**
* @return array<string, mixed>
*/
public function getOptions(): array
{
return $this->evaluate($this->options);
}
/**
* @param string | array<mixed> | Closure $rules
*/
public function fileRules(string | array | Closure $rules): static
{
$this->fileValidationRules = [
...$this->fileValidationRules,
$rules,
];
return $this;
}
/**
* @return array<mixed>
*/
public function getFileValidationRules(): array
{
$fileRules = [
'extensions:csv,txt',
fn (): Closure => function (string $attribute, mixed $value, Closure $fail): void {
$csvStream = $this->getUploadedFileStream($value);
if (! $csvStream) {
return;
}
$csvReader = CsvReader::createFromStream($csvStream);
if (filled($csvDelimiter = $this->getCsvDelimiter($csvReader))) {
$csvReader->setDelimiter($csvDelimiter);
}
$csvReader->setHeaderOffset($this->getHeaderOffset() ?? 0);
$csvColumns = $csvReader->getHeader();
$duplicateCsvColumns = [];
foreach (array_count_values($csvColumns) as $header => $count) {
if ($count <= 1) {
continue;
}
$duplicateCsvColumns[] = $header;
}
if (empty($duplicateCsvColumns)) {
return;
}
$filledDuplicateCsvColumns = array_filter($duplicateCsvColumns, fn ($value): bool => filled($value));
$fail(trans_choice('filament-actions::import.modal.form.file.rules.duplicate_columns', count($filledDuplicateCsvColumns), [
'columns' => implode(', ', $filledDuplicateCsvColumns),
]));
},
];
foreach ($this->fileValidationRules as $rules) {
$rules = $this->evaluate($rules);
if (is_string($rules)) {
$rules = explode('|', $rules);
}
$fileRules = [
...$fileRules,
...$rules,
];
}
return $fileRules;
}
public function authGuard(string | Closure | null $authGuard): static
{
$this->authGuard = $authGuard;
return $this;
}
public function getAuthGuard(): string
{
$guard = $this->evaluate($this->authGuard);
if (filled($guard)) {
return $guard;
}
if (class_exists(Filament::class) && Filament::isServing()) {
return Filament::getAuthGuard();
}
$authGuard = auth();
if (! property_exists($authGuard, 'name')) {
return config('auth.defaults.guard') ?? 'web';
}
return $authGuard->name;
}
}
@@ -0,0 +1,54 @@
<?php
namespace Filament\Actions\Imports\Events;
use Filament\Actions\Imports\Models\Import;
use Illuminate\Foundation\Events\Dispatchable;
class ImportChunkProcessed
{
use Dispatchable;
/**
* @param array<string, string> $columnMap
* @param array<string, mixed> $options
*/
public function __construct(
protected Import $import,
protected array $columnMap,
protected array $options,
protected int $processedRows,
protected int $successfulRows,
) {}
public function getImport(): Import
{
return $this->import;
}
/**
* @return array<string, string>
*/
public function getColumnMap(): array
{
return $this->columnMap;
}
/**
* @return array<string, mixed>
*/
public function getOptions(): array
{
return $this->options;
}
public function getProcessedRows(): int
{
return $this->processedRows;
}
public function getSuccessfulRows(): int
{
return $this->successfulRows;
}
}
@@ -0,0 +1,42 @@
<?php
namespace Filament\Actions\Imports\Events;
use Filament\Actions\Imports\Models\Import;
use Illuminate\Foundation\Events\Dispatchable;
class ImportCompleted
{
use Dispatchable;
/**
* @param array<string, string> $columnMap
* @param array<string, mixed> $options
*/
public function __construct(
protected Import $import,
protected array $columnMap,
protected array $options,
) {}
public function getImport(): Import
{
return $this->import;
}
/**
* @return array<string, string>
*/
public function getColumnMap(): array
{
return $this->columnMap;
}
/**
* @return array<string, mixed>
*/
public function getOptions(): array
{
return $this->options;
}
}
@@ -0,0 +1,42 @@
<?php
namespace Filament\Actions\Imports\Events;
use Filament\Actions\Imports\Models\Import;
use Illuminate\Foundation\Events\Dispatchable;
class ImportStarted
{
use Dispatchable;
/**
* @param array<string, string> $columnMap
* @param array<string, mixed> $options
*/
public function __construct(
protected Import $import,
protected array $columnMap,
protected array $options,
) {}
public function getImport(): Import
{
return $this->import;
}
/**
* @return array<string, string>
*/
public function getColumnMap(): array
{
return $this->columnMap;
}
/**
* @return array<string, mixed>
*/
public function getOptions(): array
{
return $this->options;
}
}
@@ -0,0 +1,7 @@
<?php
namespace Filament\Actions\Imports\Exceptions;
use Exception;
class RowImportFailedException extends Exception {}
@@ -0,0 +1,72 @@
<?php
namespace Filament\Actions\Imports\Http\Controllers;
use Filament\Actions\Imports\Models\FailedImportRow;
use Filament\Actions\Imports\Models\Import;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Gate;
use League\Csv\Bom;
use League\Csv\Writer;
use SplTempFileObject;
use Symfony\Component\HttpFoundation\StreamedResponse;
class DownloadImportFailureCsv
{
public function __invoke(Request $request, Import $import): StreamedResponse
{
abort_unless(auth(
$request->hasValidSignature(absolute: false)
? $request->query('authGuard')
: null,
)->check(), 401);
$user = auth(
$request->hasValidSignature(absolute: false)
? $request->query('authGuard')
: null,
)->user();
$importPolicy = Gate::getPolicyFor($import::class);
if (filled($importPolicy) && method_exists($importPolicy, 'view')) {
Gate::forUser($user)->authorize('view', Arr::wrap($import));
} else {
abort_unless($import->user()->is($user), 403);
}
$csv = Writer::createFromFileObject(new SplTempFileObject);
$csv->setOutputBOM(Bom::Utf8);
/** @var ?FailedImportRow $firstFailedRow */
$firstFailedRow = $import->failedRows()->first();
$columnHeaders = $firstFailedRow ? array_keys($firstFailedRow->data) : [];
$columnHeaders[] = __('filament-actions::import.failure_csv.error_header');
$csv->insertOne($columnHeaders);
$import->failedRows()
->lazyById(100)
->each(fn (FailedImportRow $failedImportRow) => $csv->insertOne([/** @phpstan-ignore argument.type */
...$failedImportRow->data,
'error' => $failedImportRow->validation_error ?? __('filament-actions::import.failure_csv.system_error'),
]));
return response()->streamDownload(function () use ($csv): void {
foreach ($csv->chunk(1000) as $offset => $chunk) {
echo $chunk;
if ($offset % 1000) {
flush();
}
}
}, __('filament-actions::import.failure_csv.file_name', [
'import_id' => $import->getKey(),
'csv_name' => (string) str($import->file_name)->beforeLast('.')->remove('.'),
]) . '.csv', [
'Content-Type' => 'text/csv',
]);
}
}

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