Files
filament/packages/forms/src/Components/Builder.php
T

1716 lines
60 KiB
PHP

<?php
namespace Filament\Forms\Components;
use Closure;
use Filament\Actions\Action;
use Filament\Forms\Components\Builder\Block;
use Filament\Forms\View\FormsIconAlias;
use Filament\Schemas\Components\Concerns\CanBeCollapsed;
use Filament\Schemas\Components\Contracts\CanConcealComponents;
use Filament\Schemas\Components\Contracts\HasExtraItemActions;
use Filament\Schemas\Schema;
use Filament\Support\Components\Contracts\HasEmbeddedView;
use Filament\Support\Components\ViewComponent;
use Filament\Support\Concerns\HasReorderAnimationDuration;
use Filament\Support\Enums\Alignment;
use Filament\Support\Enums\GridDirection;
use Filament\Support\Enums\Size;
use Filament\Support\Enums\Width;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
use Filament\Support\View\ComponentAttributeBag as FilamentComponentAttributeBag;
use Filament\Support\View\Components\DropdownComponent\ItemComponent;
use Filament\Support\View\Components\DropdownComponent\ItemComponent\IconComponent;
use Illuminate\Support\Arr;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Js;
use Illuminate\Support\Str;
use function Filament\Forms\array_move_after;
use function Filament\Forms\array_move_before;
use function Filament\Support\generate_icon_html;
use function Filament\Support\generate_loading_indicator_html;
class Builder extends Field implements CanConcealComponents, HasEmbeddedView, HasExtraItemActions
{
use CanBeCollapsed;
use Concerns\CanBeCloned;
use Concerns\CanGenerateUuids;
use Concerns\CanLimitItemsLength;
use Concerns\HasExtraItemActions;
use HasReorderAnimationDuration;
protected ?string $publishedViewOverrideCheckPath = 'filament-forms::components.builder';
protected string | Closure | null $addBetweenActionLabel = null;
protected string | Closure | null $addActionLabel = null;
protected bool | Closure $isReorderable = true;
protected bool | Closure $isReorderableWithDragAndDrop = true;
protected bool | Closure $isReorderableWithButtons = false;
protected bool | Closure $isAddable = true;
protected bool | Closure $isDeletable = true;
protected bool | Closure $hasBlockLabels = true;
protected bool | Closure $hasBlockNumbers = true;
protected bool | Closure $hasBlockIcons = false;
protected bool | Closure $hasBlockHeaders = true;
protected bool | Closure $hasBlockPreviews = false;
protected bool | Closure $hasInteractiveBlockPreviews = false;
protected Alignment | string | Closure | null $addActionAlignment = null;
protected ?Closure $modifyAddActionUsing = null;
protected ?Closure $modifyAddBetweenActionUsing = null;
protected ?Closure $modifyCloneActionUsing = null;
protected ?Closure $modifyDeleteActionUsing = null;
protected ?Closure $modifyMoveDownActionUsing = null;
protected ?Closure $modifyMoveUpActionUsing = null;
protected ?Closure $modifyReorderActionUsing = null;
protected ?Closure $modifyCollapseActionUsing = null;
protected ?Closure $modifyExpandActionUsing = null;
protected ?Closure $modifyCollapseAllActionUsing = null;
protected ?Closure $modifyExpandAllActionUsing = null;
protected ?Closure $modifyEditActionUsing = null;
protected string | Closure | null $labelBetweenItems = null;
protected bool | Closure $isBlockLabelTruncated = true;
/**
* @var array<string | int, int | Closure | null> | null
*/
protected ?array $blockPickerColumns = [];
protected Width | string | Closure | null $blockPickerWidth = null;
protected bool | Closure | null $shouldPartiallyRenderAfterActionsCalled = null;
/**
* @var array<?string> | null
*/
protected ?array $cachedItemsRawStateStructure = null;
protected function setUp(): void
{
parent::setUp();
$this->default([]);
$this->afterStateHydrated(static function (Builder $component): void {
$component->hydrateItems();
});
$this->registerActions([
fn (Builder $component): Action => $component->getAddAction(),
fn (Builder $component): Action => $component->getAddBetweenAction(),
fn (Builder $component): Action => $component->getCloneAction(),
fn (Builder $component): Action => $component->getCollapseAction(),
fn (Builder $component): Action => $component->getCollapseAllAction(),
fn (Builder $component): Action => $component->getDeleteAction(),
fn (Builder $component): Action => $component->getEditAction(),
fn (Builder $component): Action => $component->getExpandAction(),
fn (Builder $component): Action => $component->getExpandAllAction(),
fn (Builder $component): Action => $component->getMoveDownAction(),
fn (Builder $component): Action => $component->getMoveUpAction(),
fn (Builder $component): Action => $component->getReorderAction(),
]);
$this->mutateDehydratedStateUsing(static function (?array $state): array {
return array_values($state ?? []);
});
}
public function hydrateItems(): void
{
$items = [];
foreach ($this->getRawState() ?? [] as $itemData) {
if ($uuid = $this->generateUuid()) {
$items[$uuid] = $itemData;
} else {
$items[] = $itemData;
}
}
$this->rawState($items);
}
/**
* @param array<Block> | Closure $blocks
*/
public function blocks(array | Closure $blocks): static
{
$this->components($blocks);
return $this;
}
public function getAddAction(): Action
{
$action = Action::make($this->getAddActionName())
->label(fn (Builder $component) => $component->getAddActionLabel())
->color('gray')
->action(function (array $arguments, Builder $component, array $data = []): void {
$newUuid = $component->generateUuid();
$items = $component->getRawState();
if ($newUuid) {
$items[$newUuid] = [
'type' => $arguments['block'],
'data' => $data,
];
} else {
$items[] = [
'type' => $arguments['block'],
'data' => $data,
];
}
$component->rawState($items);
$component->getChildSchema($newUuid ?? array_key_last($items))->fill(filled($data) ? $data : null);
$component->collapsed(false, shouldMakeComponentCollapsible: false);
$component->callAfterStateUpdated();
})
->livewireClickHandlerEnabled(false)
->button()
->size(Size::Small)
->visible(fn (Builder $component): bool => $component->isAddable());
if ($this->hasBlockPreviews()) {
$action
->modalHeading(fn (Builder $component) => __('filament-forms::components.builder.actions.add.modal.heading', [
'label' => $component->getLabel(),
]))
->modalSubmitActionLabel(__('filament-forms::components.builder.actions.add.modal.actions.add.label'))
->schema(function (array $arguments, Builder $component): array {
return $component->getBlock($arguments['block'])->getClone()->getDefaultChildComponents();
});
}
if ($this->modifyAddActionUsing) {
$action = $this->evaluate($this->modifyAddActionUsing, [
'action' => $action,
]) ?? $action;
}
return $action;
}
public function addActionAlignment(Alignment | string | Closure | null $addActionAlignment): static
{
$this->addActionAlignment = $addActionAlignment;
return $this;
}
public function getAddActionAlignment(): Alignment | string | null
{
$alignment = $this->evaluate($this->addActionAlignment);
if (is_string($alignment)) {
$alignment = Alignment::tryFrom($alignment) ?? $alignment;
}
return $alignment;
}
public function addAction(?Closure $callback): static
{
$this->modifyAddActionUsing = $callback;
return $this;
}
public function getAddActionName(): string
{
return 'add';
}
public function getAddBetweenAction(): Action
{
$action = Action::make($this->getAddBetweenActionName())
->label(fn (Builder $component) => $component->getAddBetweenActionLabel())
->color('gray')
->action(function (array $arguments, Builder $component, array $data = []): void {
$newKey = $component->generateUuid();
$items = [];
foreach ($component->getRawState() ?? [] as $key => $item) {
$items[$key] = $item;
if ($key === $arguments['afterItem']) {
if ($newKey) {
$items[$newKey] = [
'type' => $arguments['block'],
'data' => $data,
];
} else {
$items[] = [
'type' => $arguments['block'],
'data' => $data,
];
$newKey = array_key_last($items);
}
}
}
$component->rawState($items);
$component->getChildSchema($newKey)->fill(filled($data) ? $data : null);
$component->collapsed(false, shouldMakeComponentCollapsible: false);
$component->callAfterStateUpdated();
$component->shouldPartiallyRenderAfterActionsCalled() ? $component->partiallyRender() : null;
})
->livewireClickHandlerEnabled(false)
->button()
->size(Size::Small)
->visible(fn (Builder $component): bool => $component->isAddable());
if ($this->hasBlockPreviews()) {
$action
->modalHeading(fn (Builder $component) => __('filament-forms::components.builder.actions.add_between.modal.heading', [
'label' => $component->getLabel(),
]))
->modalSubmitActionLabel(__('filament-forms::components.builder.actions.add_between.modal.actions.add.label'))
->schema(function (array $arguments, Builder $component): array {
return $component->getBlock($arguments['block'])->getClone()->getDefaultChildComponents();
});
}
if ($this->modifyAddBetweenActionUsing) {
$action = $this->evaluate($this->modifyAddBetweenActionUsing, [
'action' => $action,
]) ?? $action;
}
return $action;
}
public function addBetweenAction(?Closure $callback): static
{
$this->modifyAddBetweenActionUsing = $callback;
return $this;
}
public function getAddBetweenActionName(): string
{
return 'addBetween';
}
public function getCloneAction(): Action
{
$action = Action::make($this->getCloneActionName())
->label(__('filament-forms::components.builder.actions.clone.label'))
->icon(FilamentIcon::resolve(FormsIconAlias::COMPONENTS_BUILDER_ACTIONS_CLONE) ?? Heroicon::Square2Stack)
->color('gray')
->action(function (array $arguments, Builder $component): void {
$newUuid = $component->generateUuid();
$items = $component->getRawState();
if ($newUuid) {
$items[$newUuid] = $items[$arguments['item']];
} else {
$items[] = $items[$arguments['item']];
}
$component->rawState($items);
$component->collapsed(false, shouldMakeComponentCollapsible: false);
$component->callAfterStateUpdated();
$component->shouldPartiallyRenderAfterActionsCalled() ? $component->partiallyRender() : null;
})
->iconButton()
->size(Size::Small)
->visible(fn (Builder $component): bool => $component->isCloneable());
if ($this->modifyCloneActionUsing) {
$action = $this->evaluate($this->modifyCloneActionUsing, [
'action' => $action,
]) ?? $action;
}
return $action;
}
public function cloneAction(?Closure $callback): static
{
$this->modifyCloneActionUsing = $callback;
return $this;
}
public function getCloneActionName(): string
{
return 'clone';
}
public function getDeleteAction(): Action
{
$action = Action::make($this->getDeleteActionName())
->label(__('filament-forms::components.builder.actions.delete.label'))
->icon(FilamentIcon::resolve(FormsIconAlias::COMPONENTS_BUILDER_ACTIONS_DELETE) ?? Heroicon::Trash)
->color('danger')
->action(function (array $arguments, Builder $component): void {
$items = $component->getRawState();
unset($items[$arguments['item']]);
$component->rawState($items);
$component->callAfterStateUpdated();
$component->shouldPartiallyRenderAfterActionsCalled() ? $component->partiallyRender() : null;
})
->iconButton()
->size(Size::Small)
->visible(fn (Builder $component): bool => $component->isDeletable());
if ($this->modifyDeleteActionUsing) {
$action = $this->evaluate($this->modifyDeleteActionUsing, [
'action' => $action,
]) ?? $action;
}
return $action;
}
public function deleteAction(?Closure $callback): static
{
$this->modifyDeleteActionUsing = $callback;
return $this;
}
public function getDeleteActionName(): string
{
return 'delete';
}
public function getMoveDownAction(): Action
{
$action = Action::make($this->getMoveDownActionName())
->label(__('filament-forms::components.builder.actions.move_down.label'))
->icon(FilamentIcon::resolve(FormsIconAlias::COMPONENTS_BUILDER_ACTIONS_MOVE_DOWN) ?? Heroicon::ArrowDown)
->color('gray')
->action(function (array $arguments, Builder $component): void {
$items = array_move_after($component->getRawState(), $arguments['item']);
$component->rawState($items);
$component->callAfterStateUpdated();
$component->shouldPartiallyRenderAfterActionsCalled() ? $component->partiallyRender() : null;
})
->iconButton()
->size(Size::Small)
->visible(fn (Builder $component): bool => $component->isReorderable());
if ($this->modifyMoveDownActionUsing) {
$action = $this->evaluate($this->modifyMoveDownActionUsing, [
'action' => $action,
]) ?? $action;
}
return $action;
}
public function moveDownAction(?Closure $callback): static
{
$this->modifyMoveDownActionUsing = $callback;
return $this;
}
public function getMoveDownActionName(): string
{
return 'moveDown';
}
public function getMoveUpAction(): Action
{
$action = Action::make($this->getMoveUpActionName())
->label(__('filament-forms::components.builder.actions.move_up.label'))
->icon(FilamentIcon::resolve(FormsIconAlias::COMPONENTS_BUILDER_ACTIONS_MOVE_UP) ?? Heroicon::ArrowUp)
->color('gray')
->action(function (array $arguments, Builder $component): void {
$items = array_move_before($component->getRawState(), $arguments['item']);
$component->rawState($items);
$component->callAfterStateUpdated();
$component->shouldPartiallyRenderAfterActionsCalled() ? $component->partiallyRender() : null;
})
->iconButton()
->size(Size::Small)
->visible(fn (Builder $component): bool => $component->isReorderable());
if ($this->modifyMoveUpActionUsing) {
$action = $this->evaluate($this->modifyMoveUpActionUsing, [
'action' => $action,
]) ?? $action;
}
return $action;
}
public function moveUpAction(?Closure $callback): static
{
$this->modifyMoveUpActionUsing = $callback;
return $this;
}
public function labelBetweenItems(string | Closure | null $label): static
{
$this->labelBetweenItems = $label;
return $this;
}
public function getMoveUpActionName(): string
{
return 'moveUp';
}
public function getReorderAction(): Action
{
$action = Action::make($this->getReorderActionName())
->label(__('filament-forms::components.builder.actions.reorder.label'))
->icon(FilamentIcon::resolve(FormsIconAlias::COMPONENTS_BUILDER_ACTIONS_REORDER) ?? Heroicon::ArrowsUpDown)
->color('gray')
->action(function (array $arguments, Builder $component): void {
$items = [
...array_flip($arguments['items']),
...$component->getRawState(),
];
$component->rawState($items);
$component->callAfterStateUpdated();
$component->shouldPartiallyRenderAfterActionsCalled() ? $component->partiallyRender() : null;
})
->livewireClickHandlerEnabled(false)
->iconButton()
->size(Size::Small)
->visible(fn (Builder $component): bool => $component->isReorderableWithDragAndDrop());
if ($this->modifyReorderActionUsing) {
$action = $this->evaluate($this->modifyReorderActionUsing, [
'action' => $action,
]) ?? $action;
}
return $action;
}
public function reorderAction(?Closure $callback): static
{
$this->modifyReorderActionUsing = $callback;
return $this;
}
public function getReorderActionName(): string
{
return 'reorder';
}
public function getCollapseAction(): Action
{
$action = Action::make($this->getCollapseActionName())
->label(__('filament-forms::components.builder.actions.collapse.label'))
->icon(FilamentIcon::resolve(FormsIconAlias::COMPONENTS_BUILDER_ACTIONS_COLLAPSE) ?? Heroicon::ChevronUp)
->color('gray')
->livewireClickHandlerEnabled(false)
->iconButton()
->size(Size::Small);
if ($this->modifyCollapseActionUsing) {
$action = $this->evaluate($this->modifyCollapseActionUsing, [
'action' => $action,
]) ?? $action;
}
return $action;
}
public function collapseAction(?Closure $callback): static
{
$this->modifyCollapseActionUsing = $callback;
return $this;
}
public function getCollapseActionName(): string
{
return 'collapse';
}
public function getExpandAction(): Action
{
$action = Action::make($this->getExpandActionName())
->label(__('filament-forms::components.builder.actions.expand.label'))
->icon(FilamentIcon::resolve(FormsIconAlias::COMPONENTS_BUILDER_ACTIONS_EXPAND) ?? Heroicon::ChevronDown)
->color('gray')
->livewireClickHandlerEnabled(false)
->iconButton()
->size(Size::Small);
if ($this->modifyExpandActionUsing) {
$action = $this->evaluate($this->modifyExpandActionUsing, [
'action' => $action,
]) ?? $action;
}
return $action;
}
public function expandAction(?Closure $callback): static
{
$this->modifyExpandActionUsing = $callback;
return $this;
}
public function getExpandActionName(): string
{
return 'expand';
}
public function getCollapseAllAction(): Action
{
$action = Action::make($this->getCollapseAllActionName())
->label(__('filament-forms::components.builder.actions.collapse_all.label'))
->color('gray')
->livewireClickHandlerEnabled(false)
->link()
->size(Size::Small);
if ($this->modifyCollapseAllActionUsing) {
$action = $this->evaluate($this->modifyCollapseAllActionUsing, [
'action' => $action,
]) ?? $action;
}
return $action;
}
public function collapseAllAction(?Closure $callback): static
{
$this->modifyCollapseAllActionUsing = $callback;
return $this;
}
public function getCollapseAllActionName(): string
{
return 'collapseAll';
}
public function getExpandAllAction(): Action
{
$action = Action::make($this->getExpandAllActionName())
->label(__('filament-forms::components.builder.actions.expand_all.label'))
->color('gray')
->livewireClickHandlerEnabled(false)
->link()
->size(Size::Small);
if ($this->modifyExpandAllActionUsing) {
$action = $this->evaluate($this->modifyExpandAllActionUsing, [
'action' => $action,
]) ?? $action;
}
return $action;
}
public function expandAllAction(?Closure $callback): static
{
$this->modifyExpandAllActionUsing = $callback;
return $this;
}
public function getExpandAllActionName(): string
{
return 'expandAll';
}
public function getEditAction(): Action
{
$action = Action::make($this->getEditActionName())
->label(__('filament-forms::components.builder.actions.edit.label'))
->modalHeading(__('filament-forms::components.builder.actions.edit.modal.heading'))
->modalSubmitActionLabel(__('filament-forms::components.builder.actions.edit.modal.actions.save.label'))
->color('gray')
->fillForm(function (array $arguments, Builder $component) {
$state = $component->getState();
return $state[$arguments['item']]['data'];
})
->schema(function (array $arguments, Builder $component) {
return $component->getChildSchema($arguments['item'])
->getClone()
->getComponents(withHidden: true);
})
->action(function (array $arguments, Builder $component, $data): void {
$state = $component->getRawState();
$state[$arguments['item']]['data'] = $data;
$component->rawState($state);
$component->getChildSchema($arguments['item'])->fill($data);
$component->callAfterStateUpdated();
$component->shouldPartiallyRenderAfterActionsCalled() ? $component->partiallyRender() : null;
})
->iconButton()
->icon(Heroicon::Cog6Tooth)
->size(Size::Small)
->visible(fn (Builder $component): bool => (! $component->isDisabled()) && $component->hasBlockPreviews());
if ($this->modifyEditActionUsing) {
$action = $this->evaluate($this->modifyEditActionUsing, [
'action' => $action,
]) ?? $action;
}
return $action;
}
public function editAction(?Closure $callback): static
{
$this->modifyEditActionUsing = $callback;
return $this;
}
public function getEditActionName(): string
{
return 'edit';
}
public function truncateBlockLabel(bool | Closure $condition = true): static
{
$this->isBlockLabelTruncated = $condition;
return $this;
}
public function addBetweenActionLabel(string | Closure | null $label): static
{
$this->addBetweenActionLabel = $label;
return $this;
}
/**
* @deprecated Use `addBetweenActionLabel()` instead.
*/
public function createItemBetweenButtonLabel(string | Closure | null $label): static
{
$this->addBetweenActionLabel($label);
return $this;
}
public function addActionLabel(string | Closure | null $label): static
{
$this->addActionLabel = $label;
return $this;
}
/**
* @deprecated Use `addActionLabel()` instead.
*/
public function createItemButtonLabel(string | Closure | null $label): static
{
$this->addActionLabel($label);
return $this;
}
public function addable(bool | Closure $condition = true): static
{
$this->isAddable = $condition;
return $this;
}
public function deletable(bool | Closure $condition = true): static
{
$this->isDeletable = $condition;
return $this;
}
public function reorderable(bool | Closure $condition = true): static
{
$this->isReorderable = $condition;
return $this;
}
/**
* @deprecated Use `addable()` instead.
*/
public function disableItemCreation(bool | Closure $condition = true): static
{
$this->addable(fn (Builder $component): bool => ! $this->evaluate($condition));
return $this;
}
/**
* @deprecated Use `deletable()` instead.
*/
public function disableItemDeletion(bool | Closure $condition = true): static
{
$this->deletable(fn (Builder $component): bool => ! $this->evaluate($condition));
return $this;
}
/**
* @deprecated Use `reorderable()` instead.
*/
public function disableItemMovement(bool | Closure $condition = true): static
{
$this->reorderable(fn (Builder $component): bool => ! $this->evaluate($condition));
return $this;
}
/**
* @deprecated No longer part of the design system.
*/
public function inset(bool | Closure $condition = true): static
{
return $this;
}
public function reorderableWithDragAndDrop(bool | Closure $condition = true): static
{
$this->isReorderableWithDragAndDrop = $condition;
return $this;
}
public function reorderableWithButtons(bool | Closure $condition = true): static
{
$this->isReorderableWithButtons = $condition;
return $this;
}
/**
* @deprecated Use `blockLabels()` instead.
*/
public function showBlockLabels(bool | Closure $condition = true): static
{
$this->withBlockLabels($condition);
return $this;
}
/**
* @deprecated Use `blockLabels()` instead.
*/
public function withBlockLabels(bool | Closure $condition = true): static
{
$this->blockLabels($condition);
return $this;
}
/**
* @deprecated Use `blockNumbers()` instead.
*/
public function withBlockNumbers(bool | Closure $condition = true): static
{
$this->blockNumbers($condition);
return $this;
}
public function blockLabels(bool | Closure $condition = true): static
{
$this->hasBlockLabels = $condition;
return $this;
}
public function blockNumbers(bool | Closure $condition = true): static
{
$this->hasBlockNumbers = $condition;
return $this;
}
public function blockIcons(bool | Closure $condition = true): static
{
$this->hasBlockIcons = $condition;
return $this;
}
public function blockPreviews(bool | Closure $condition = true, bool | Closure $areInteractive = false): static
{
$this->hasBlockPreviews = $condition;
$this->hasInteractiveBlockPreviews = $areInteractive;
return $this;
}
public function getBlock(string $name): ?Block
{
return Arr::first(
$this->getBlocks(),
fn (Block $block): bool => $block->getName() === $name,
);
}
/**
* @return array<Block>
*/
public function getBlocks(): array
{
/** @var array<Block> $blocks */
$blocks = $this->getChildSchema()->getComponents();
return $blocks;
}
/**
* @return array<Schema>
*/
public function getItems(): array
{
return $this->getCachedDefaultChildSchemas();
}
/**
* @return array<Schema>
*/
public function getDefaultChildSchemas(): array
{
$rawState = $this->getRawState();
$this->cachedItemsRawStateStructure = $this->getRawStateStructure($rawState);
$blocks = [];
foreach ($this->getBlocks() as $block) {
$blocks[$block->getName()] = $block;
}
return collect($rawState)
->filter(fn (array $itemData): bool => filled($itemData['type'] ?? null) && array_key_exists($itemData['type'], $blocks))
->map(
fn (array $itemData, $itemIndex): Schema => $blocks[$itemData['type']]
->getChildSchema()
->statePath("{$itemIndex}.data")
->constantState($itemData['data'] ?? [])
->inlineLabel(false)
->getClone(),
)
->all();
}
/**
* Item schemas only depend on the raw state's structure - the item keys, their
* order, and each item's block type - since fields inside the items read their
* values from the live raw state. Comparing a structural fingerprint instead of
* the item values keeps this check cheap when it runs often, and avoids
* rebuilding the item schemas every time a value inside an item changes.
*/
protected function areCachedDefaultChildSchemasFresh(): bool
{
return $this->cachedItemsRawStateStructure === $this->getRawStateStructure($this->getRawState());
}
/**
* @return array<?string>
*/
protected function getRawStateStructure(mixed $rawState): array
{
return array_map(
static fn (mixed $itemData): ?string => is_array($itemData) ? ($itemData['type'] ?? null) : null,
is_array($rawState) ? $rawState : [],
);
}
public function getAddBetweenActionLabel(): string
{
return $this->evaluate($this->addBetweenActionLabel) ?? __('filament-forms::components.builder.actions.add_between.label');
}
public function getAddActionLabel(): string
{
return $this->evaluate($this->addActionLabel) ?? __('filament-forms::components.builder.actions.add.label', [
'label' => Str::lcfirst($this->getLabel()),
]);
}
public function hasBlock(string $name): bool
{
return (bool) $this->getBlock($name);
}
public function isReorderable(): bool
{
if ($this->isDisabled()) {
return false;
}
return (bool) $this->evaluate($this->isReorderable);
}
public function isReorderableWithDragAndDrop(): bool
{
return $this->evaluate($this->isReorderableWithDragAndDrop) && $this->isReorderable();
}
public function isReorderableWithButtons(): bool
{
return $this->evaluate($this->isReorderableWithButtons) && $this->isReorderable();
}
public function isAddable(): bool
{
if ($this->isDisabled()) {
return false;
}
if (filled($this->getMaxItems()) && ($this->getMaxItems() <= $this->getItemsCount())) {
return false;
}
return (bool) $this->evaluate($this->isAddable);
}
public function isDeletable(): bool
{
if ($this->isDisabled()) {
return false;
}
return (bool) $this->evaluate($this->isDeletable);
}
public function hasBlockLabels(): bool
{
return (bool) $this->evaluate($this->hasBlockLabels);
}
public function hasBlockNumbers(): bool
{
return (bool) $this->evaluate($this->hasBlockNumbers);
}
public function hasBlockIcons(): bool
{
return (bool) $this->evaluate($this->hasBlockIcons);
}
public function hasBlockPreviews(): bool
{
return (bool) $this->evaluate($this->hasBlockPreviews);
}
public function hasInteractiveBlockPreviews(): bool
{
return (bool) $this->evaluate($this->hasInteractiveBlockPreviews);
}
public function canConcealComponents(): bool
{
return $this->isCollapsible();
}
public function getLabelBetweenItems(): ?string
{
return $this->evaluate($this->labelBetweenItems);
}
public function isBlockLabelTruncated(): bool
{
return (bool) $this->evaluate($this->isBlockLabelTruncated);
}
/**
* @return array<Block>
*/
public function getBlockPickerBlocks(): array
{
$state = $this->getRawState();
/** @var array<Block> $blocks */
$blocks = array_filter($this->getBlocks(), function (Block $block) use ($state): bool {
/** @var Block $block */
$maxItems = $block->getMaxItems();
if ($maxItems === null) {
return true;
}
$count = count(array_filter($state, function (array $item) use ($block): bool {
return $item['type'] === $block->getName();
}));
return $count < $maxItems;
});
return $blocks;
}
/**
* @param array<string, int | Closure | null> | int | Closure | null $columns
*/
public function blockPickerColumns(array | int | Closure | null $columns = 2): static
{
if ($columns instanceof Closure) {
$this->blockPickerColumns[] = $columns;
return $this;
}
if (! is_array($columns)) {
$columns = [
'lg' => $columns,
];
}
$this->blockPickerColumns = [
...($this->blockPickerColumns ?? []),
...$columns,
];
return $this;
}
/**
* @return array<string, ?int> | int | null
*/
public function getBlockPickerColumns(?string $breakpoint = null): array | int | null
{
$columns = $this->blockPickerColumns ?? [
'default' => 1,
'sm' => null,
'md' => null,
'lg' => null,
'xl' => null,
'2xl' => null,
];
foreach ($this->blockPickerColumns ?? [] as $columnBreakpoint => $column) {
$column = $this->evaluate($column);
if (is_array($column)) {
$columns = [
...$columns,
...$column,
];
unset($columns[$columnBreakpoint]);
continue;
}
if (blank($columnBreakpoint)) {
unset($columns[$columnBreakpoint]);
continue;
}
if (! is_string($columnBreakpoint)) {
$columns['lg'] = $column;
unset($columns[$columnBreakpoint]);
continue;
}
$columns[$columnBreakpoint] = $column;
}
if ($breakpoint !== null) {
return $columns[$breakpoint] ?? null;
}
return $columns;
}
public function blockPickerWidth(Width | string | Closure | null $width): static
{
$this->blockPickerWidth = $width;
return $this;
}
public function getBlockPickerWidth(): Width | string | null
{
$width = $this->evaluate($this->blockPickerWidth);
if (filled($width)) {
return $width;
}
$columns = $this->getBlockPickerColumns();
if (empty($columns)) {
return null;
}
return match (max($columns)) {
2 => 'md',
3 => '2xl',
4 => '4xl',
5 => '6xl',
6 => '7xl',
default => null,
};
}
/**
* @return array<string, mixed>
*/
public function getItemState(string $key): array
{
return $this->getChildSchema($key)->getState(shouldCallHooksBefore: false);
}
/**
* @return array<string, mixed>
*/
public function getRawItemState(string $key): array
{
return $this->getChildSchema($key)->getStateSnapshot();
}
public function getHeadingsCount(): int
{
if (! $this->hasBlockLabels()) {
return 0;
}
return 1;
}
/**
* @param array<string, array<mixed>> $rules
*/
public function dehydrateValidationRules(array &$rules): void
{
parent::dehydrateValidationRules($rules);
$rules["{$this->getStatePath()}.*.type"] = ['required'];
}
public function partiallyRenderAfterActionsCalled(bool | Closure | null $condition = true): static
{
$this->shouldPartiallyRenderAfterActionsCalled = $condition;
return $this;
}
public function shouldPartiallyRenderAfterActionsCalled(): bool
{
$condition = $this->evaluate($this->shouldPartiallyRenderAfterActionsCalled);
if ($condition !== null) {
return (bool) $condition;
}
return ! $this->isLive();
}
public function blockHeaders(bool | Closure $condition = true): static
{
$this->hasBlockHeaders = $condition;
return $this;
}
public function hasBlockHeaders(): bool
{
return (bool) $this->evaluate($this->hasBlockHeaders);
}
/**
* @param array<Block> $blocks
* @param array<string, ?int> | int | null $columns
*/
protected function generateBlockPickerHtml(
Action $action,
array $blocks,
string $key,
string $triggerHtml,
Alignment | string | null $actionAlignment = null,
?string $afterItem = null,
array | int | null $columns = null,
Width | string | null $width = null,
): string {
/** @var view-string $publishedView */
$publishedView = 'filament-forms::components.builder.block-picker';
if (ViewComponent::hasPublishedEmbeddedViewOverride($publishedView)) {
return view($publishedView, [
'action' => $action,
'actionAlignment' => $actionAlignment,
'afterItem' => $afterItem,
'blocks' => $blocks,
'columns' => $columns,
'key' => $key,
'trigger' => new HtmlString($triggerHtml),
'width' => $width,
])->render();
}
$placement = match ($actionAlignment) {
Alignment::Start, Alignment::Left => 'bottom-start',
Alignment::End, Alignment::Right => 'bottom-end',
default => null,
};
$alignmentClass = ($actionAlignment instanceof Alignment)
? ('fi-align-' . $actionAlignment->value)
: (is_string($actionAlignment) ? $actionAlignment : null);
if (is_string($width)) {
$width = Width::tryFrom($width) ?? $width;
}
$widthClass = ($width instanceof Width)
? "fi-width-{$width->value}"
: (is_string($width) ? $width : null);
$xFloatDirective = 'x-float' . ($placement ? ".placement.{$placement}" : '') . '.flip.shift.offset';
$dropdownAttributes = (new FilamentComponentAttributeBag)
->merge(['x-data' => 'filamentDropdown'], escape: false)
->class([
'fi-dropdown',
'fi-fo-builder-block-picker',
$alignmentClass,
]);
$panelAttributes = (new FilamentComponentAttributeBag)
->merge([
'x-cloak' => true,
'x-ref' => 'panel',
'x-transition:enter-start' => 'fi-opacity-0',
'x-transition:leave-end' => 'fi-opacity-0',
$xFloatDirective => '{ offset: 8 }',
], escape: false)
->class([
'fi-dropdown-panel',
$widthClass,
]);
$loadingDelay = config('filament.livewire_loading_delay', 'default');
ob_start(); ?>
<div <?= $dropdownAttributes->toHtml() ?>>
<div
x-on:keyup.enter="toggle($event)"
x-on:keyup.space="toggle($event)"
x-on:mousedown="if ($event.button === 0) toggle($event)"
class="fi-dropdown-trigger"
>
<?= $triggerHtml ?>
</div>
<?php if (filled($blocks)) { ?>
<div <?= $panelAttributes->toHtml() ?>>
<div class="fi-dropdown-list">
<div <?= (new FilamentComponentAttributeBag)->grid($columns, GridDirection::Column)->toHtml() ?>>
<?php foreach ($blocks as $block) {
$blockIcon = $block->getIcon();
$wireClickArguments = ['block' => $block->getName()];
if (filled($afterItem)) {
$wireClickArguments['afterItem'] = $afterItem;
}
$wireClick = "mountAction('{$action->getName()}', " . Js::from($wireClickArguments) . ", { schemaComponent: '{$key}' })";
$itemAttributes = (new FilamentComponentAttributeBag)
->merge([
'x-on:click' => 'close',
'wire:click' => $wireClick,
'type' => 'button',
'wire:loading.attr' => 'disabled',
'wire:target' => $wireClick,
], escape: false)
->class(['fi-dropdown-list-item'])
->color(ItemComponent::class, 'gray');
?>
<button <?= $itemAttributes->toHtml() ?>>
<?php if (filled($blockIcon)) { ?>
<?= generate_icon_html($blockIcon, attributes: (new FilamentComponentAttributeBag([
'wire:loading.remove.delay.' . $loadingDelay => true,
'wire:target' => $wireClick,
]))->color(IconComponent::class, 'gray'))?->toHtml() ?>
<?php } ?>
<?= generate_loading_indicator_html((new FilamentComponentAttributeBag([
'wire:loading.delay.' . $loadingDelay => '',
'wire:target' => $wireClick,
]))->color(IconComponent::class, 'gray'))->toHtml() ?>
<span class="fi-dropdown-list-item-label">
<?= e($block->getLabel()) ?>
</span>
</button>
<?php } ?>
</div>
</div>
</div>
<?php } ?>
</div>
<?php return ob_get_clean();
}
public function toEmbeddedHtml(): string
{
$items = $this->getItems();
// Filter before counting so `$itemCount` agrees with the loop's
// `$isFirst` / `$isLast` calculations.
$items = array_filter(
$items,
static fn ($item): bool => $item->getParentComponent() instanceof Block,
);
$blockPickerBlocks = $this->getBlockPickerBlocks();
$blockPickerColumns = $this->getBlockPickerColumns();
$blockPickerWidth = $this->getBlockPickerWidth();
$hasBlockPreviews = $this->hasBlockPreviews();
$hasInteractiveBlockPreviews = $this->hasInteractiveBlockPreviews();
$addAction = $this->getAction($this->getAddActionName());
$addActionAlignment = $this->getAddActionAlignment();
$addBetweenAction = $this->getAction($this->getAddBetweenActionName());
$cloneAction = $this->getAction($this->getCloneActionName());
$collapseAllAction = $this->getAction($this->getCollapseAllActionName());
$editAction = $this->getAction($this->getEditActionName());
$expandAllAction = $this->getAction($this->getExpandAllActionName());
$deleteAction = $this->getAction($this->getDeleteActionName());
$moveDownAction = $this->getAction($this->getMoveDownActionName());
$moveUpAction = $this->getAction($this->getMoveUpActionName());
$reorderAction = $this->getAction($this->getReorderActionName());
$extraItemActions = $this->getExtraItemActions();
$isAddable = $this->isAddable();
$isCloneable = $this->isCloneable();
$isCollapsible = $this->isCollapsible();
$isDeletable = $this->isDeletable();
$isReorderableWithButtons = $this->isReorderableWithButtons();
$isReorderableWithDragAndDrop = $this->isReorderableWithDragAndDrop();
$collapseAllActionIsVisible = $isCollapsible && $collapseAllAction->isVisible();
$expandAllActionIsVisible = $isCollapsible && $expandAllAction->isVisible();
$persistCollapsed = $this->shouldPersistCollapsed();
$key = $this->getKey();
$statePath = $this->getStatePath();
$blockLabelHeadingTag = $this->getHeadingTag();
$isBlockLabelTruncated = $this->isBlockLabelTruncated();
$labelBetweenItems = $this->getLabelBetweenItems();
$id = $this->getId();
$outerAttributes = (new FilamentComponentAttributeBag)
->merge($this->getExtraAttributes(), escape: false)
->merge([
'aria-labelledby' => "{$id}-label",
'id' => $id,
'role' => 'group',
], escape: false)
->class([
'fi-fo-builder',
'fi-collapsible' => $isCollapsible,
]);
$itemCount = count($items);
$itemIndex = 0;
$hasBlockLabels = $this->hasBlockLabels();
$hasBlockIcons = $this->hasBlockIcons();
$hasBlockNumbers = $this->hasBlockNumbers();
$hasBlockHeaders = $this->hasBlockHeaders();
ob_start(); ?>
<div <?= $outerAttributes->toHtml() ?>>
<?php if ($collapseAllActionIsVisible || $expandAllActionIsVisible) { ?>
<div
<?= (new FilamentComponentAttributeBag)->class([
'fi-fo-builder-actions',
'fi-hidden' => $itemCount < 2,
])->toHtml() ?>
>
<?php if ($collapseAllActionIsVisible) { ?>
<span x-on:click="$dispatch('builder-collapse', '<?= e($statePath) ?>')">
<?= $collapseAllAction->toHtml() ?>
</span>
<?php } ?>
<?php if ($expandAllActionIsVisible) { ?>
<span x-on:click="$dispatch('builder-expand', '<?= e($statePath) ?>')">
<?= $expandAllAction->toHtml() ?>
</span>
<?php } ?>
</div>
<?php } ?>
<?php if ($itemCount) { ?>
<ul
x-sortable
data-sortable-animation-duration="<?= e($this->getReorderAnimationDuration()) ?>"
x-on:end.stop="$wire.mountAction('reorder', { items: $event.target.sortable.toArray() }, { schemaComponent: '<?= e($key) ?>' })"
class="fi-fo-builder-items"
>
<?php foreach ($items as $itemKey => $item) {
/** @var Block $block */
$block = $item->getParentComponent();
$itemIndex++;
$isFirst = $itemIndex === 1;
$isLast = $itemIndex === $itemCount;
$visibleExtraItemActions = array_filter(
$extraItemActions,
fn (Action $action): bool => $action(['item' => $itemKey])->isVisible(),
);
$itemCloneAction = $cloneAction(['item' => $itemKey]);
$cloneActionIsVisible = $isCloneable && $itemCloneAction->isVisible();
$itemDeleteAction = $deleteAction(['item' => $itemKey]);
$deleteActionIsVisible = $isDeletable && $itemDeleteAction->isVisible();
$itemEditAction = $editAction(['item' => $itemKey]);
$editActionIsVisible = $hasBlockPreviews && $itemEditAction->isVisible();
$itemMoveDownAction = $moveDownAction(['item' => $itemKey])->disabled($isLast);
$moveDownActionIsVisible = $isReorderableWithButtons && $itemMoveDownAction->isVisible();
$itemMoveUpAction = $moveUpAction(['item' => $itemKey])->disabled($isFirst);
$moveUpActionIsVisible = $isReorderableWithButtons && $itemMoveUpAction->isVisible();
$reorderActionIsVisible = $isReorderableWithDragAndDrop && $reorderAction->isVisible();
$hasItemHeader = $hasBlockHeaders && ($reorderActionIsVisible || $moveUpActionIsVisible || $moveDownActionIsVisible || $hasBlockIcons || $hasBlockLabels || $editActionIsVisible || $cloneActionIsVisible || $deleteActionIsVisible || $isCollapsible || $visibleExtraItemActions);
?>
<li
wire:ignore.self
wire:key="<?= e($item->getLivewireKey()) ?>.item"
x-data="{
isCollapsed: <?php if ($persistCollapsed) { ?>$persist(<?= Js::from($this->isCollapsed($item)) ?>).as(`builder-${<?= Js::from($key) ?>}-${<?= Js::from($itemKey) ?>}-isCollapsed`)<?php } else { ?><?= Js::from($this->isCollapsed($item)) ?><?php } ?>,
}"
x-on:builder-expand.window="$event.detail === '<?= e($statePath) ?>' && (isCollapsed = false)"
x-on:builder-collapse.window="$event.detail === '<?= e($statePath) ?>' && (isCollapsed = true)"
x-on:expand="isCollapsed = false"
x-sortable-item="<?= e($itemKey) ?>"
<?= $block->getExtraAttributeBag()
->class([
'fi-fo-builder-item',
'fi-fo-builder-item-has-header' => $hasItemHeader,
])->toHtml() ?>
x-bind:class="{ 'fi-collapsed': isCollapsed }"
>
<?php if ($hasItemHeader) { ?>
<div
<?php if ($isCollapsible) { ?>
x-on:click.stop="isCollapsed = !isCollapsed"
<?php } ?>
class="fi-fo-builder-item-header"
>
<?php if ($reorderActionIsVisible || $moveUpActionIsVisible || $moveDownActionIsVisible) { ?>
<ul class="fi-fo-builder-item-header-start-actions">
<?php if ($reorderActionIsVisible) { ?>
<li x-on:click.stop>
<?= $reorderAction->extraAttributes(['x-sortable-handle' => true], merge: true)->toHtml() ?>
</li>
<?php } ?>
<?php if ($moveUpActionIsVisible || $moveDownActionIsVisible) { ?>
<li x-on:click.stop><?= $itemMoveUpAction->toHtml() ?></li>
<li x-on:click.stop><?= $itemMoveDownAction->toHtml() ?></li>
<?php } ?>
</ul>
<?php } ?>
<?php
$blockIcon = $block->getIcon();
?>
<?php if ($hasBlockIcons && filled($blockIcon)) { ?>
<?= generate_icon_html($blockIcon, attributes: (new FilamentComponentAttributeBag)->class(['fi-fo-builder-item-header-icon']))?->toHtml() ?>
<?php } ?>
<?php if ($hasBlockLabels) { ?>
<<?= e($blockLabelHeadingTag) ?>
<?= (new FilamentComponentAttributeBag)->class([
'fi-fo-builder-item-header-label',
'fi-truncated' => $isBlockLabelTruncated,
])->toHtml() ?>
>
<?= e($block->getLabel($item->getRawState(), $itemKey, $itemIndex - 1)) ?>
<?php if ($hasBlockNumbers) { ?>
<?= e($itemIndex) ?>
<?php } ?>
</<?= e($blockLabelHeadingTag) ?>>
<?php } ?>
<?php if ($editActionIsVisible || $cloneActionIsVisible || $deleteActionIsVisible || $isCollapsible || $visibleExtraItemActions) { ?>
<ul class="fi-fo-builder-item-header-end-actions">
<?php foreach ($visibleExtraItemActions as $extraItemAction) { ?>
<li x-on:click.stop><?= $extraItemAction(['item' => $itemKey])->toHtml() ?></li>
<?php } ?>
<?php if ($editActionIsVisible) { ?>
<li x-on:click.stop><?= $itemEditAction->toHtml() ?></li>
<?php } ?>
<?php if ($cloneActionIsVisible) { ?>
<li x-on:click.stop><?= $itemCloneAction->toHtml() ?></li>
<?php } ?>
<?php if ($deleteActionIsVisible) { ?>
<li x-on:click.stop><?= $itemDeleteAction->toHtml() ?></li>
<?php } ?>
<?php if ($isCollapsible) { ?>
<li class="fi-fo-builder-item-header-collapsible-actions" x-on:click.stop="isCollapsed = !isCollapsed">
<div class="fi-fo-builder-item-header-collapse-action">
<?= $this->getAction('collapse')->toHtml() ?>
</div>
<div class="fi-fo-builder-item-header-expand-action">
<?= $this->getAction('expand')->toHtml() ?>
</div>
</li>
<?php } ?>
</ul>
<?php } ?>
</div>
<?php } ?>
<div
x-show="! isCollapsed"
<?= (new FilamentComponentAttributeBag)->class([
'fi-fo-builder-item-content',
'fi-fo-builder-item-content-has-preview' => $hasBlockPreviews && $block->hasPreview(),
])->toHtml() ?>
>
<?php if ($hasBlockPreviews && $block->hasPreview()) { ?>
<div
<?= (new FilamentComponentAttributeBag)->class([
'fi-fo-builder-item-preview',
'fi-interactive' => $hasInteractiveBlockPreviews,
])->toHtml() ?>
>
<?= $block->renderPreview($item->getRawState())->render() ?>
</div>
<?php if ($editActionIsVisible && (! $hasInteractiveBlockPreviews)) { ?>
<div
class="fi-fo-builder-item-preview-edit-overlay"
role="button"
x-on:click.stop="<?= e('$wire.mountAction(\'edit\', { item: \'' . $itemKey . '\' }, { schemaComponent: \'' . $key . '\' })') ?>"
></div>
<?php } ?>
<?php } else { ?>
<?= $item->toHtml() ?>
<?php } ?>
</div>
</li>
<?php if (! $isLast) { ?>
<?php if ($isAddable && $addBetweenAction(['afterItem' => $itemKey])->isVisible()) { ?>
<li class="fi-fo-builder-add-between-items-ctn">
<div class="fi-fo-builder-add-between-items">
<div class="fi-fo-builder-block-picker-ctn">
<?= $this->generateBlockPickerHtml(
action: $addBetweenAction,
blocks: $blockPickerBlocks,
key: $key,
triggerHtml: $addBetweenAction(['afterItem' => $itemKey])->toHtml(),
afterItem: $itemKey,
columns: $blockPickerColumns,
width: $blockPickerWidth,
) ?>
</div>
</div>
</li>
<?php } elseif (filled($labelBetweenItems)) { ?>
<li class="fi-fo-builder-label-between-items-ctn">
<div class="fi-fo-builder-label-between-items-divider-before"></div>
<span class="fi-fo-builder-label-between-items"><?= e($labelBetweenItems) ?></span>
<div class="fi-fo-builder-label-between-items-divider-after"></div>
</li>
<?php } ?>
<?php } ?>
<?php } ?>
</ul>
<?php } ?>
<?php if ($isAddable && $addAction->isVisible()) { ?>
<?= $this->generateBlockPickerHtml(
action: $addAction,
blocks: $blockPickerBlocks,
key: $key,
triggerHtml: $addAction->toHtml(),
actionAlignment: $addActionAlignment,
columns: $blockPickerColumns,
width: $blockPickerWidth,
) ?>
<?php } ?>
</div>
<?php return $this->wrapEmbeddedHtml(ob_get_clean(), labelTag: 'div');
}
}