diff --git a/CHANGELOG.md b/CHANGELOG.md index bb3c904bd3a..68858ac535b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Added fluent APIs for creating and modifying `CraftCms\Cms\FieldLayout\FieldLayout`, `CraftCms\Cms\FieldLayout\FieldLayoutTab`, and field layout elements. +- Changed `CraftCms\Cms\FieldLayout\LayoutElements\BaseField::label()` to accept an optional label and return the field layout element when one is passed. Overrides must accept the new optional argument. +- Renamed the protected `CraftCms\Cms\FieldLayout\LayoutElements\BaseField::instructions()`, `tip()`, and `warning()` methods to `instructionsText()`, `tipText()`, and `warningText()`. - Updated core asset I/O to resolve Craft filesystem definitions and configured storage targets through Laravel filesystem disks. - Updated elevated session prompts to use the modern control panel frontend while preserving the legacy JavaScript APIs. - Login attempts are now rate limited. diff --git a/database/Factories/FieldLayoutFactory.php b/database/Factories/FieldLayoutFactory.php index a5101265dfd..e8b4e80f399 100644 --- a/database/Factories/FieldLayoutFactory.php +++ b/database/Factories/FieldLayoutFactory.php @@ -7,9 +7,11 @@ use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\Field\Field; use CraftCms\Cms\Field\Models\Field as FieldModel; +use CraftCms\Cms\FieldLayout\FieldLayout as FieldLayoutConfig; +use CraftCms\Cms\FieldLayout\FieldLayoutElement; +use CraftCms\Cms\FieldLayout\FieldLayoutTab; use CraftCms\Cms\FieldLayout\LayoutElements\CustomField; use CraftCms\Cms\FieldLayout\Models\FieldLayout; -use CraftCms\Cms\Support\Str; use Illuminate\Database\Eloquent\Factories\Factory; class FieldLayoutFactory extends Factory @@ -24,30 +26,23 @@ public function definition(): array ]; } - public function withContentTab(array $elements = [], string $name = 'Content'): self + /** @param FieldLayoutElement[] $elements */ + public function withContentTab(array $elements = [], ?string $name = null): self { - return $this->state(fn () => [ - 'config' => [ - 'tabs' => [ - [ - 'uid' => Str::uuid()->toString(), - 'name' => $name, - 'elements' => $elements, - ], - ], - ], - ]); + return $this->state(function (array $attributes) use ($elements, $name) { + $layout = FieldLayoutConfig::create($attributes['type']); + $layout->tab($name ?? FieldLayoutConfig::defaultTabName(), fn (FieldLayoutTab $tab) => $tab->add(...$elements)); + + return ['config' => $layout->getConfig()]; + }); } public function forField(Field|FieldModel $field, bool $required = false): self { - return $this->withContentTab([ - [ - 'uid' => Str::uuid()->toString(), - 'type' => CustomField::class, - 'fieldUid' => $field->uid, - 'required' => $required, - ], - ]); + $element = $field instanceof FieldModel + ? new CustomField(config: ['fieldUid' => $field->uid]) + : CustomField::for($field); + + return $this->withContentTab([$element->required($required)]); } } diff --git a/src/Address/Addresses.php b/src/Address/Addresses.php index e8c901b6e19..5f50ff8e14b 100644 --- a/src/Address/Addresses.php +++ b/src/Address/Addresses.php @@ -232,7 +232,7 @@ public function getFieldLayout(): FieldLayout if (! $firstTab) { $firstTab = new FieldLayoutTab([ 'layout' => $fieldLayout, - 'name' => t('Content'), + 'name' => FieldLayout::defaultTabName(), ]); $fieldLayout->setTabs([$firstTab]); } diff --git a/src/Cp/FieldLayoutDesigner/FieldLayoutDesigner.php b/src/Cp/FieldLayoutDesigner/FieldLayoutDesigner.php index 57844407f9b..d30bb0ab6ce 100644 --- a/src/Cp/FieldLayoutDesigner/FieldLayoutDesigner.php +++ b/src/Cp/FieldLayoutDesigner/FieldLayoutDesigner.php @@ -42,7 +42,7 @@ public function html(FieldLayout $fieldLayout, array $config = []): string 'uid' => Str::uuid()->toString(), 'layout' => $fieldLayout, ]); - $tab->name = $config['pretendTabName'] ?? t('Content'); + $tab->name = $config['pretendTabName'] ?? FieldLayout::defaultTabName(); // Any extra tabs? if (! empty($tabs)) { diff --git a/src/FieldLayout/FieldLayout.php b/src/FieldLayout/FieldLayout.php index 8d4a7516cce..e977867b421 100644 --- a/src/FieldLayout/FieldLayout.php +++ b/src/FieldLayout/FieldLayout.php @@ -43,6 +43,7 @@ use function CraftCms\Cms\t; +/** @phpstan-consistent-constructor */ class FieldLayout extends Component { use LegacyConstants; @@ -154,6 +155,85 @@ public function __construct( } } + public static function defaultTabName(): string + { + return t('Content'); + } + + /** + * @param class-string $type + */ + public static function create(string $type): static + { + return new static(['type' => $type]); + } + + /** + * Creates or modifies a tab by name. + * + * Use {@see defaultTabName()} to target the tab created for mandatory fields. + * + * @param Closure(FieldLayoutTab): mixed $closure + */ + public function tab(string $name, Closure $closure): static + { + $tab = array_find($this->getTabs(), fn (FieldLayoutTab $tab) => $tab->name === $name); + + if ($tab === null) { + $tab = new FieldLayoutTab([ + 'layout' => $this, + 'name' => $name, + 'elements' => [], + ]); + $this->setTabs([...$this->getTabs(), $tab]); + } + + $closure($tab); + + return $this; + } + + public function getTab(string $name): FieldLayoutTab + { + return array_find($this->getTabs(), fn (FieldLayoutTab $tab) => $tab->name === $name) + ?? throw new InvalidArgumentException(sprintf('Unknown tab: %s', $name)); + } + + public function removeField(FieldInterface|string $field): static + { + if (is_string($field)) { + $field = Fields::getFieldByHandle($field) + ?? throw new InvalidArgumentException(sprintf('Unknown field handle: %s', $field)); + } + + foreach ($this->getTabs() as $tab) { + $elements = array_filter( + $tab->getElements(), + fn (FieldLayoutElement $element) => ! $element instanceof CustomField || $element->getFieldUid() !== $field->uid, + ); + + if (count($elements) !== count($tab->getElements())) { + $tab->setElements(array_values($elements)); + } + } + + return $this; + } + + public function removeTab(string $name): static + { + $tabs = array_values(array_filter( + $this->getTabs(), + fn (FieldLayoutTab $tab) => $tab->name !== $name, + )); + + if (count($tabs) !== count($this->getTabs())) { + $this->setTabs($tabs); + } + + return $this; + } + /** * Creates a new field layout from the given config. */ @@ -817,7 +897,7 @@ public function prependElements(array $elements): void $this->_tabs[] = $tab = new FieldLayoutTab([ 'layout' => $this, 'layoutId' => $this->id, - 'name' => t('Content'), + 'name' => static::defaultTabName(), 'sortOrder' => 1, 'elements' => [], ]); diff --git a/src/FieldLayout/FieldLayoutComponent.php b/src/FieldLayout/FieldLayoutComponent.php index a41f4f6c4e4..afd41fcc3ce 100644 --- a/src/FieldLayout/FieldLayoutComponent.php +++ b/src/FieldLayout/FieldLayoutComponent.php @@ -127,6 +127,13 @@ public function setUserCondition(mixed $userCondition): void $this->_userCondition = $userCondition; } + public function userCondition(mixed $userCondition): static + { + $this->setUserCondition($userCondition); + + return $this; + } + public function getElementCondition(): ?ElementConditionInterface { if (isset($this->_elementCondition) && ! $this->_elementCondition instanceof ElementConditionInterface) { @@ -155,6 +162,13 @@ public function setElementCondition(mixed $elementCondition): void $this->_elementCondition = $elementCondition; } + public function elementCondition(mixed $elementCondition): static + { + $this->setElementCondition($elementCondition); + + return $this; + } + /** * Normalizes a condition. * diff --git a/src/FieldLayout/FieldLayoutElement.php b/src/FieldLayout/FieldLayoutElement.php index 84dee97c378..95abd34780a 100644 --- a/src/FieldLayout/FieldLayoutElement.php +++ b/src/FieldLayout/FieldLayoutElement.php @@ -51,6 +51,13 @@ public function hasCustomWidth(): bool return false; } + public function width(int $width): static + { + $this->width = $width; + + return $this; + } + /** * Returns the selector HTML that should be displayed within field layout designers. */ diff --git a/src/FieldLayout/FieldLayoutTab.php b/src/FieldLayout/FieldLayoutTab.php index 576ae972b08..6966e758315 100644 --- a/src/FieldLayout/FieldLayoutTab.php +++ b/src/FieldLayout/FieldLayoutTab.php @@ -4,13 +4,21 @@ namespace CraftCms\Cms\FieldLayout; +use Closure; use CraftCms\Cms\Cp\FormFields; use CraftCms\Cms\Cp\Icons; use CraftCms\Cms\Element\Contracts\ElementInterface; +use CraftCms\Cms\Field\Contracts\FieldInterface; use CraftCms\Cms\Field\Exceptions\FieldNotFoundException; use CraftCms\Cms\Field\Fields; use CraftCms\Cms\FieldLayout\LayoutElements\BaseField; use CraftCms\Cms\FieldLayout\LayoutElements\CustomField; +use CraftCms\Cms\FieldLayout\LayoutElements\Heading; +use CraftCms\Cms\FieldLayout\LayoutElements\HorizontalRule; +use CraftCms\Cms\FieldLayout\LayoutElements\LineBreak; +use CraftCms\Cms\FieldLayout\LayoutElements\Markdown; +use CraftCms\Cms\FieldLayout\LayoutElements\Template; +use CraftCms\Cms\FieldLayout\LayoutElements\Tip; use CraftCms\Cms\Plugin\Plugins; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Html; @@ -287,6 +295,114 @@ public function setElements(array $elements): void } } + /** + * Adds elements to the tab. + * + * Multi-instance custom fields need a unique handle override when included more than once. + */ + public function add(FieldLayoutElement ...$elements): static + { + $fieldUids = array_fill_keys(array_map( + fn (CustomField $element) => $element->getFieldUid(), + $this->getLayout()->getElementsByType(CustomField::class), + ), true); + + foreach ($elements as $element) { + if ($element instanceof CustomField) { + $fieldUid = $element->getFieldUid(); + + if (isset($fieldUids[$fieldUid]) && ! $element->isMultiInstance()) { + throw new InvalidArgumentException(sprintf( + 'The field "%s" is already included in this field layout.', + $element->attribute(), + )); + } + + $fieldUids[$fieldUid] = true; + } + } + + foreach ($elements as $element) { + $element->dateAdded ??= now(); + } + + $this->setElements([...$this->getElements(), ...$elements]); + + return $this; + } + + /** @param (Closure(CustomField): mixed)|null $configure */ + public function field(FieldInterface|string $field, ?Closure $configure = null): static + { + $element = CustomField::for($field); + $configure?->__invoke($element); + + return $this->add($element); + } + + /** @param (Closure(Heading): mixed)|null $configure */ + public function heading(string $heading, ?Closure $configure = null): static + { + $element = new Heading($heading); + $configure?->__invoke($element); + + return $this->add($element); + } + + /** @param (Closure(Tip): mixed)|null $configure */ + public function tip(string $tip, ?Closure $configure = null): static + { + $element = new Tip($tip); + $configure?->__invoke($element); + + return $this->add($element); + } + + /** @param (Closure(Tip): mixed)|null $configure */ + public function warning(string $warning, ?Closure $configure = null): static + { + $element = new Tip($warning)->warning(); + $configure?->__invoke($element); + + return $this->add($element); + } + + /** @param (Closure(Markdown): mixed)|null $configure */ + public function markdown(string $content, ?Closure $configure = null): static + { + $element = new Markdown($content); + $configure?->__invoke($element); + + return $this->add($element); + } + + /** @param (Closure(Template): mixed)|null $configure */ + public function template(string $template, ?Closure $configure = null): static + { + $element = new Template($template); + $configure?->__invoke($element); + + return $this->add($element); + } + + /** @param (Closure(HorizontalRule): mixed)|null $configure */ + public function horizontalRule(?Closure $configure = null): static + { + $element = new HorizontalRule; + $configure?->__invoke($element); + + return $this->add($element); + } + + /** @param (Closure(LineBreak): mixed)|null $configure */ + public function lineBreak(?Closure $configure = null): static + { + $element = new LineBreak; + $configure?->__invoke($element); + + return $this->add($element); + } + public function getHtmlId(): string { $asciiName = isset($this->name) ? Str::kebab(Str::ascii($this->name)) : ''; diff --git a/src/FieldLayout/LayoutElements/BaseField.php b/src/FieldLayout/LayoutElements/BaseField.php index f49e4c2ffe9..ac7b7ad229b 100644 --- a/src/FieldLayout/LayoutElements/BaseField.php +++ b/src/FieldLayout/LayoutElements/BaseField.php @@ -342,9 +342,9 @@ public function formHtml(?ElementInterface $element = null, bool $static = false $showStatus = $this->showStatus(); $statusClass = $showStatus ? $this->statusClass($element, $static) : null; $label = $this->showLabel() ? $this->label() : null; - $instructions = $this->instructions($element, $static); - $tip = $this->tip($element, $static); - $warning = $this->warning($element, $static); + $instructions = $this->instructionsText($element, $static); + $tip = $this->tipText($element, $static); + $warning = $this->warningText($element, $static); $translatable = $this->translatable($element, $static); $actionMenuItems = $this->actionMenuItems($element, $static); @@ -530,9 +530,9 @@ protected function describedBy(?ElementInterface $element = null, bool $static = $ids = array_filter([ (! $static && $this->fieldErrors($element)) ? $this->errorsId() : null, $this->statusClass($element, $static) ? $this->statusId() : null, - $this->instructions($element, $static) ? $this->instructionsId() : null, - $this->tip($element, $static) ? $this->tipId() : null, - $this->warning($element, $static) ? $this->warningId() : null, + $this->instructionsText($element, $static) ? $this->instructionsId() : null, + $this->tipText($element, $static) ? $this->tipId() : null, + $this->warningText($element, $static) ? $this->warningId() : null, ]); return $ids ? implode(' ', $ids) : null; @@ -588,10 +588,16 @@ protected function labelAttributes(?ElementInterface $element = null, bool $stat } /** - * Returns the field’s label. + * Returns or sets the field’s label. */ - public function label(): ?string + public function label(?string $label = null): static|string|null { + if (func_num_args() !== 0) { + $this->label = $label; + + return $this; + } + if (isset($this->label) && $this->label !== '' && $this->label !== '__blank__') { return t($this->label, category: 'site'); } @@ -599,6 +605,39 @@ public function label(): ?string return $this->defaultLabel(); } + public function instructions(?string $instructions): static + { + $this->instructions = $instructions; + + return $this; + } + + public function tip(?string $tip): static + { + $this->tip = $tip; + + return $this; + } + + public function warning(?string $warning): static + { + $this->warning = $warning; + + return $this; + } + + public function required(bool $required = true): static + { + $this->required = $required; + + return $this; + } + + public function labelHidden(bool $labelHidden = true): static + { + return $this->label($labelHidden ? '__blank__' : null); + } + /** * Returns the field’s default label, which will be used if [[label]] is null. * @@ -662,7 +701,7 @@ protected function statusLabel(?ElementInterface $element = null, bool $static = * @param ElementInterface|null $element The element the form is being rendered for * @param bool $static Whether the form should be static (non-interactive) */ - protected function instructions(?ElementInterface $element = null, bool $static = false): ?string + protected function instructionsText(?ElementInterface $element = null, bool $static = false): ?string { return $this->instructions ? t($this->instructions, category: 'site') : $this->defaultInstructions($element, $static); } @@ -692,7 +731,7 @@ abstract protected function inputHtml(?ElementInterface $element = null, bool $s * @param ElementInterface|null $element The element the form is being rendered for * @param bool $static Whether the form should be static (non-interactive) */ - protected function tip(?ElementInterface $element = null, bool $static = false): ?string + protected function tipText(?ElementInterface $element = null, bool $static = false): ?string { return $this->tip ? t($this->tip, category: 'site') : null; } @@ -703,7 +742,7 @@ protected function tip(?ElementInterface $element = null, bool $static = false): * @param ElementInterface|null $element The element the form is being rendered for * @param bool $static Whether the form should be static (non-interactive) */ - protected function warning(?ElementInterface $element = null, bool $static = false): ?string + protected function warningText(?ElementInterface $element = null, bool $static = false): ?string { return $this->warning ? t($this->warning, category: 'site') : null; } diff --git a/src/FieldLayout/LayoutElements/CustomField.php b/src/FieldLayout/LayoutElements/CustomField.php index 0fb9e7d162b..97e10d8d30d 100644 --- a/src/FieldLayout/LayoutElements/CustomField.php +++ b/src/FieldLayout/LayoutElements/CustomField.php @@ -25,6 +25,7 @@ use CraftCms\Cms\Support\Str; use CraftCms\Cms\User\Conditions\UserCondition; use CraftCms\Cms\User\Elements\User; +use InvalidArgumentException; use Override; use RuntimeException; use Throwable; @@ -40,6 +41,8 @@ * @property FieldInterface $field The custom field this layout field is based on * @property string $fieldUid The UID of the field this layout field is based on * @property UserCondition|null $editCondition The user condition which determines who can edit this field + * + * @phpstan-consistent-constructor */ class CustomField extends BaseField { @@ -114,6 +117,69 @@ public function __construct(?FieldInterface $field = null, $config = []) } } + public static function for(FieldInterface|string $field): static + { + if (is_string($field)) { + $field = Fields::getFieldByHandle($field) + ?? throw new InvalidArgumentException(sprintf('Unknown field handle: %s', $field)); + } + + return new static($field); + } + + #[Override] + public function label(?string $label = null): static|string|null + { + if (func_num_args() === 0) { + return parent::label(); + } + + parent::label($label); + + if ($this->_field !== null) { + $this->_field->name = $label ?? $this->_originalName; + } + + return $this; + } + + #[Override] + public function instructions(?string $instructions): static + { + parent::instructions($instructions); + + if ($this->_field !== null) { + $this->_field->instructions = $instructions ?? $this->_originalInstructions; + } + + return $this; + } + + public function handle(?string $handle): static + { + $this->handle = $handle; + + if ($this->_field !== null) { + $this->_field->handle = $handle ?? $this->_originalHandle; + } + + return $this; + } + + public function editCondition(mixed $editCondition): static + { + $this->setEditCondition($editCondition); + + return $this; + } + + public function elementEditCondition(mixed $elementEditCondition): static + { + $this->setElementEditCondition($elementEditCondition); + + return $this; + } + #[Override] public function isMultiInstance(): bool { diff --git a/src/FieldLayout/LayoutElements/Heading.php b/src/FieldLayout/LayoutElements/Heading.php index d3f97e7ff32..46b6c711fae 100644 --- a/src/FieldLayout/LayoutElements/Heading.php +++ b/src/FieldLayout/LayoutElements/Heading.php @@ -21,6 +21,11 @@ class Heading extends BaseUiElement */ public string $heading = ''; + public function __construct(string|array|object $config = []) + { + parent::__construct(is_string($config) ? ['heading' => $config] : $config); + } + protected function selectorLabel(): string { return $this->heading ?: t('Heading'); diff --git a/src/FieldLayout/LayoutElements/Markdown.php b/src/FieldLayout/LayoutElements/Markdown.php index 549974fc4ed..357819edc3c 100644 --- a/src/FieldLayout/LayoutElements/Markdown.php +++ b/src/FieldLayout/LayoutElements/Markdown.php @@ -25,6 +25,18 @@ class Markdown extends BaseUiElement */ public bool $displayInPane = true; + public function __construct(string|array|object $config = []) + { + parent::__construct(is_string($config) ? ['content' => $config] : $config); + } + + public function displayInPane(bool $displayInPane = true): static + { + $this->displayInPane = $displayInPane; + + return $this; + } + protected function selectorLabel(): string { return Str::firstLine($this->content) ?: 'Markdown'; diff --git a/src/FieldLayout/LayoutElements/Template.php b/src/FieldLayout/LayoutElements/Template.php index e96a08d386b..e2b385fa78c 100644 --- a/src/FieldLayout/LayoutElements/Template.php +++ b/src/FieldLayout/LayoutElements/Template.php @@ -45,6 +45,18 @@ private static function twig(): Environment */ public string $templateMode = TemplateMode::Site->value; + public function __construct(string|array|object $config = []) + { + parent::__construct(is_string($config) ? ['template' => $config] : $config); + } + + public function templateMode(TemplateMode|string $templateMode): static + { + $this->templateMode = $templateMode instanceof TemplateMode ? $templateMode->value : $templateMode; + + return $this; + } + protected function selectorLabel(): string { return $this->template ?: t('Template'); diff --git a/src/FieldLayout/LayoutElements/Tip.php b/src/FieldLayout/LayoutElements/Tip.php index 804d027503d..3d5df1a368b 100644 --- a/src/FieldLayout/LayoutElements/Tip.php +++ b/src/FieldLayout/LayoutElements/Tip.php @@ -32,6 +32,25 @@ class Tip extends BaseUiElement */ public string $style = self::STYLE_TIP; + public function __construct(string|array|object $config = []) + { + parent::__construct(is_string($config) ? ['tip' => $config] : $config); + } + + public function dismissible(bool $dismissible = true): static + { + $this->dismissible = $dismissible; + + return $this; + } + + public function warning(bool $warning = true): static + { + $this->style = $warning ? self::STYLE_WARNING : self::STYLE_TIP; + + return $this; + } + protected function selectorLabel(): string { $tip = trim($this->tip); diff --git a/src/FieldLayout/LayoutElements/Users/AffiliatedSiteField.php b/src/FieldLayout/LayoutElements/Users/AffiliatedSiteField.php index aac014c3cb2..05d8bbbc314 100644 --- a/src/FieldLayout/LayoutElements/Users/AffiliatedSiteField.php +++ b/src/FieldLayout/LayoutElements/Users/AffiliatedSiteField.php @@ -52,7 +52,7 @@ public function defaultLabel(?ElementInterface $element = null, bool $static = f } #[Override] - protected function instructions(?ElementInterface $element = null, bool $static = false): ?string + protected function instructionsText(?ElementInterface $element = null, bool $static = false): ?string { return t('Determines which site the user will receive emails from, when sent via the control panel.'); } diff --git a/src/FieldLayout/LayoutElements/Users/EmailField.php b/src/FieldLayout/LayoutElements/Users/EmailField.php index d141c103c06..d78ece310a4 100644 --- a/src/FieldLayout/LayoutElements/Users/EmailField.php +++ b/src/FieldLayout/LayoutElements/Users/EmailField.php @@ -66,7 +66,7 @@ public function defaultLabel(?ElementInterface $element = null, bool $static = f } #[Override] - protected function warning(?ElementInterface $element = null, bool $static = false): ?string + protected function warningText(?ElementInterface $element = null, bool $static = false): ?string { /** @var User $element */ if ( diff --git a/tests/Feature/Field/FieldsTest.php b/tests/Feature/Field/FieldsTest.php index ab54f581417..4583935d219 100644 --- a/tests/Feature/Field/FieldsTest.php +++ b/tests/Feature/Field/FieldsTest.php @@ -18,6 +18,7 @@ use CraftCms\Cms\Field\MissingField; use CraftCms\Cms\Field\Models\Field as FieldModel; use CraftCms\Cms\Field\PlainText; +use CraftCms\Cms\FieldLayout\FieldLayout; use CraftCms\Cms\FieldLayout\FieldLayoutTab; use CraftCms\Cms\FieldLayout\LayoutElements\CustomField as CustomFieldElement; use CraftCms\Cms\FieldLayout\Models\FieldLayout as FieldLayoutModel; @@ -317,23 +318,10 @@ class CustomNestedEntryField extends Field {} 'handle' => 'plainTextLayoutField', 'type' => PlainText::class, ]); + FieldsFacade::refreshFields(); - $layout = $this->fields->createLayout([ - 'type' => EntryElement::class, - 'tabs' => [ - [ - 'uid' => Str::uuid()->toString(), - 'name' => 'Content', - 'elements' => [ - [ - 'uid' => Str::uuid()->toString(), - 'type' => CustomFieldElement::class, - 'fieldUid' => $field->uid, - ], - ], - ], - ], - ]); + $layout = FieldLayout::create(EntryElement::class) + ->tab(FieldLayout::defaultTabName(), fn (FieldLayoutTab $tab) => $tab->add(CustomFieldElement::for($field->handle))); expect($this->fields->saveLayout($layout))->toBeTrue(); @@ -520,24 +508,10 @@ class CustomNestedEntryField extends Field {} 'handle' => 'plainTextLayoutField', 'type' => PlainText::class, ]); + FieldsFacade::refreshFields(); - $layout = $this->fields->createLayout([ - 'uid' => Str::uuid()->toString(), - 'type' => EntryElement::class, - 'tabs' => [ - [ - 'uid' => Str::uuid()->toString(), - 'name' => 'Content', - 'elements' => [ - [ - 'uid' => Str::uuid()->toString(), - 'type' => CustomFieldElement::class, - 'fieldUid' => $field->uid, - ], - ], - ], - ], - ]); + $layout = FieldLayout::create(EntryElement::class) + ->tab(FieldLayout::defaultTabName(), fn (FieldLayoutTab $tab) => $tab->add(CustomFieldElement::for($field->handle))); expect($this->fields->saveLayout($layout))->toBeTrue() ->and($layout->id)->toBeInt() @@ -545,14 +519,14 @@ class CustomNestedEntryField extends Field {} ->and($this->fields->getLayoutByUid($layout->uid)?->id)->toBe($layout->id) ->and($this->fields->getLayoutByType(EntryElement::class, false)?->id)->toBe($layout->id); - $layout->setTabs([ - new FieldLayoutTab([ - 'layout' => $layout, - 'uid' => Str::uuid()->toString(), - 'name' => 'Updated', - ]), - ]); + $layout + ->removeTab(FieldLayout::defaultTabName()) + ->tab('Updated', fn (FieldLayoutTab $tab) => $tab->add(CustomFieldElement::for($field->handle))); - expect($this->fields->saveLayout($layout))->toBeTrue() - ->and($this->fields->getLayoutById($layout->id)?->getTabs()[0]->name)->toBe('Updated'); + expect($this->fields->saveLayout($layout))->toBeTrue(); + + $savedLayout = $this->fields->getLayoutById($layout->id); + + expect($savedLayout?->getTabs())->toHaveCount(1) + ->and($savedLayout?->getTab('Updated'))->toBeInstanceOf(FieldLayoutTab::class); }); diff --git a/tests/Feature/FieldLayout/FluentBuilderTest.php b/tests/Feature/FieldLayout/FluentBuilderTest.php new file mode 100644 index 00000000000..566443a8e34 --- /dev/null +++ b/tests/Feature/FieldLayout/FluentBuilderTest.php @@ -0,0 +1,76 @@ +field = FieldModel::factory()->create([ + 'name' => 'Body', + 'handle' => 'body', + 'type' => PlainText::class, + ]); + Fields::refreshFields(); +}); + +it('resolves custom fields by handle', function () { + $element = CustomField::for('body'); + + expect($element->getFieldUid())->toBe($this->field->uid) + ->and($element->attribute())->toBe('body'); +}); + +it('adds custom fields by handle', function () { + $layout = new FieldLayout; + $layout->tab(FieldLayout::defaultTabName(), fn (FieldLayoutTab $tab) => $tab + ->field('body', fn (CustomField $field) => $field->required())); + + $element = $layout->getTab(FieldLayout::defaultTabName())->getElements()[0]; + + expect($element)->toBeInstanceOf(CustomField::class) + ->and($element->getFieldUid())->toBe($this->field->uid) + ->and($element->required)->toBeTrue(); +}); + +it('rejects unknown field handles', function () { + expect(fn () => CustomField::for('missing')) + ->toThrow(InvalidArgumentException::class); +}); + +it('removes fields by handle and rejects unknown handles', function () { + $layout = new FieldLayout; + $layout->tab(FieldLayout::defaultTabName(), fn (FieldLayoutTab $tab) => $tab->add(CustomField::for('body'))); + + expect($layout->removeField('body'))->toBe($layout) + ->and($layout->getCustomFields())->toBeEmpty() + ->and(fn () => $layout->removeField('missing')) + ->toThrow(InvalidArgumentException::class); +}); + +it('modifies and persists a factory-created field layout', function () { + $layoutModel = FieldLayoutModel::factory()->withContentTab()->create(); + $entryTypeModel = EntryType::factory()->withFieldLayout($layoutModel)->create(); + EntryTypes::refreshEntryTypes(); + + $layout = EntryTypes::getEntryTypeById($entryTypeModel->id)->getFieldLayout(); + $layout->tab('SEO', fn (FieldLayoutTab $tab) => $tab->add(CustomField::for('body')->required())); + + expect(Fields::saveLayout($layout))->toBeTrue(); + + $savedLayout = Fields::getLayoutById($layout->id); + $savedField = array_find( + $savedLayout->getTab('SEO')->getElements(), + fn ($element) => $element instanceof CustomField && $element->getFieldUid() === $this->field->uid, + ); + + expect($savedField)->toBeInstanceOf(CustomField::class) + ->and($savedField?->required)->toBeTrue(); +}); diff --git a/tests/Unit/FieldLayout/FluentBuilderTest.php b/tests/Unit/FieldLayout/FluentBuilderTest.php new file mode 100644 index 00000000000..770b344cc45 --- /dev/null +++ b/tests/Unit/FieldLayout/FluentBuilderTest.php @@ -0,0 +1,276 @@ + ucfirst($handle), + 'handle' => $handle, + 'uid' => Str::uuid()->toString(), + ]); +} + +it('creates a layout for an element type', function () { + expect(FieldLayout::create(Entry::class)->type)->toBe(Entry::class); +}); + +it('uses the translated default tab name', function () { + Event::listen(NativeFieldsResolving::class, function (NativeFieldsResolving $event) { + $event->fields[] = new TextField([ + 'attribute' => 'title', + 'mandatory' => true, + ]); + }); + + I18N::withLocale('de', null, function () { + $layout = new FieldLayout; + $name = FieldLayout::defaultTabName(); + $tab = $layout->getTab($name); + + $layout->tab($name, fn (FieldLayoutTab $tab) => $tab->add(new Heading('Metadata'))); + + expect($name)->toBe('Inhalt') + ->and($layout->getTabs())->toBe([$tab]) + ->and($tab->getElements())->toHaveCount(2); + }); +}); + +it('creates and reuses attached tabs', function () { + $layout = new FieldLayout; + $content = new Heading('Content'); + $metadata = new Heading('Metadata'); + + expect($layout->tab(FieldLayout::defaultTabName(), fn (FieldLayoutTab $tab) => $tab->add($content)))->toBe($layout); + + $tab = $layout->getTab(FieldLayout::defaultTabName()); + $layout->tab(FieldLayout::defaultTabName(), fn (FieldLayoutTab $tab) => $tab->add($metadata)); + + expect($tab->getLayout())->toBe($layout) + ->and($layout->getTabs())->toBe([$tab]) + ->and($tab->getElements())->toBe([$content, $metadata]); +}); + +it('rejects unknown tab names', function () { + expect(fn () => new FieldLayout()->getTab('Missing')) + ->toThrow(InvalidArgumentException::class); +}); + +it('adds elements with back references and dates', function () { + $layout = new FieldLayout; + $element = CustomField::for(fluentField()); + $layout->tab(FieldLayout::defaultTabName(), fn (FieldLayoutTab $tab) => $tab->add($element)); + $tab = $layout->getTab(FieldLayout::defaultTabName()); + + expect($tab->getElements())->toBe([$element]) + ->and($element->getLayout())->toBe($layout) + ->and($element->dateAdded)->not()->toBeNull(); +}); + +it('rejects duplicate single-instance fields including within one batch', function () { + $field = new class(['name' => 'Computed', 'handle' => 'computed', 'uid' => Str::uuid()->toString()]) extends PlainText + { + #[Override] + public static function dbType(): null + { + return null; + } + }; + $layout = new FieldLayout; + $layout->tab(FieldLayout::defaultTabName(), fn (FieldLayoutTab $tab) => $tab->add(CustomField::for($field))); + $tab = $layout->getTab(FieldLayout::defaultTabName()); + $batchLayout = new FieldLayout; + + expect(fn () => $tab->add(CustomField::for($field))) + ->toThrow(InvalidArgumentException::class) + ->and(fn () => $batchLayout->tab(FieldLayout::defaultTabName(), fn (FieldLayoutTab $tab) => $tab->add(CustomField::for($field), CustomField::for($field)))) + ->toThrow(InvalidArgumentException::class); +}); + +it('allows duplicate multi-instance fields', function () { + $field = fluentField(); + $layout = new FieldLayout; + $body = CustomField::for($field); + $secondaryBody = CustomField::for($field)->handle('secondaryBody'); + + $layout->tab(FieldLayout::defaultTabName(), fn (FieldLayoutTab $tab) => $tab->add( + $body, + $secondaryBody, + )); + + expect($layout->getTab(FieldLayout::defaultTabName())->getElements())->toBe([$body, $secondaryBody]); +}); + +it('configures field elements fluently', function () { + $userCondition = User::createCondition(); + $elementCondition = Entry::createCondition(); + $field = CustomField::for(fluentField()) + ->width(50) + ->label('Teaser') + ->instructions('Keep it short.') + ->tip('Optional tip') + ->warning('Required warning') + ->required(false) + ->userCondition($userCondition) + ->elementCondition($elementCondition) + ->editCondition($userCondition) + ->elementEditCondition($elementCondition); + + expect($field->width)->toBe(50) + ->and($field->label())->toBe('Teaser') + ->and($field->instructions)->toBe('Keep it short.') + ->and($field->tip)->toBe('Optional tip') + ->and($field->warning)->toBe('Required warning') + ->and($field->required)->toBeFalse() + ->and($field->getUserCondition())->toBe($userCondition) + ->and($field->getElementCondition())->toBe($elementCondition) + ->and($field->getEditCondition())->toBe($userCondition) + ->and($field->getElementEditCondition())->toBe($elementCondition); +}); + +it('keeps custom field overrides synchronized', function () { + $layout = new FieldLayout; + $field = CustomField::for(fluentField()) + ->handle('teaser') + ->label('Teaser') + ->instructions('Short copy'); + + $layout->tab(FieldLayout::defaultTabName(), fn (FieldLayoutTab $tab) => $tab->add($field)); + + expect($field->attribute())->toBe('teaser') + ->and($field->getField()->name)->toBe('Teaser') + ->and($field->getField()->instructions)->toBe('Short copy') + ->and($layout->getFieldByHandle('teaser'))->toBe($field->getField()); + + $field->handle(null)->labelHidden(false)->instructions(null); + + expect($field->attribute())->toBe('body') + ->and($field->label())->toBe('Body') + ->and($field->getField()->instructions)->toBeNull(); +}); + +it('builds UI elements fluently', function () { + $tip = new Tip('Careful')->dismissible()->warning(); + $markdown = new Markdown('Hello')->displayInPane(false); + $template = new Template('_includes/card')->templateMode(TemplateMode::Cp); + + expect(new Heading('Metadata')->heading)->toBe('Metadata') + ->and($tip->tip)->toBe('Careful') + ->and($tip->dismissible)->toBeTrue() + ->and($tip->style)->toBe(Tip::STYLE_WARNING) + ->and($tip->warning(false)->style)->toBe(Tip::STYLE_TIP) + ->and($markdown->content)->toBe('Hello') + ->and($markdown->displayInPane)->toBeFalse() + ->and($template->template)->toBe('_includes/card') + ->and($template->templateMode)->toBe(TemplateMode::Cp->value) + ->and($template->templateMode('site')->templateMode)->toBe('site') + ->and(new HorizontalRule)->toBeInstanceOf(HorizontalRule::class) + ->and(new LineBreak)->toBeInstanceOf(LineBreak::class); +}); + +it('adds elements with tab helpers', function () { + $layout = new FieldLayout; + + $layout->tab(FieldLayout::defaultTabName(), fn (FieldLayoutTab $tab) => $tab + ->field(fluentField(), fn (CustomField $field) => $field->required()) + ->heading('Metadata') + ->tip('Keep this concise.', fn (Tip $tip) => $tip->dismissible()) + ->warning('This is required.') + ->markdown('## Details') + ->template('_includes/card') + ->horizontalRule() + ->lineBreak()); + + $elements = $layout + ->getTab(FieldLayout::defaultTabName()) + ->getElements(); + + expect($elements)->toHaveCount(8) + ->and(array_any($elements, fn ($element) => $element instanceof CustomField && $element->required))->toBeTrue() + ->and(array_any($elements, fn ($element) => $element instanceof Heading && $element->heading === 'Metadata'))->toBeTrue() + ->and(array_any($elements, fn ($element) => $element instanceof Tip && $element->dismissible))->toBeTrue() + ->and(array_any($elements, fn ($element) => $element instanceof Tip && $element->style === Tip::STYLE_WARNING))->toBeTrue() + ->and(array_any($elements, fn ($element) => $element instanceof Markdown))->toBeTrue() + ->and(array_any($elements, fn ($element) => $element instanceof Template))->toBeTrue() + ->and(array_any($elements, fn ($element) => $element instanceof HorizontalRule))->toBeTrue() + ->and(array_any($elements, fn ($element) => $element instanceof LineBreak))->toBeTrue(); +}); + +it('invalidates field caches when fields are added and removed', function () { + $layout = new FieldLayout; + $field = fluentField(); + + expect($layout->getCustomFields())->toBeEmpty(); + + $layout->tab(FieldLayout::defaultTabName(), fn (FieldLayoutTab $tab) => $tab->add(CustomField::for($field))); + + expect($layout->getCustomFields())->toHaveCount(1); + + $layout->removeField($field); + + expect($layout->getCustomFields())->toBeEmpty(); +}); + +it('removes a field from every tab', function () { + $layout = new FieldLayout; + $field = fluentField(); + $layout->tab(FieldLayout::defaultTabName(), fn (FieldLayoutTab $tab) => $tab->add(CustomField::for($field))); + $layout->tab('SEO', fn (FieldLayoutTab $tab) => $tab->add(CustomField::for($field)->handle('seoBody'))); + + $layout->removeField($field); + + expect($layout->getAllElements())->toBeEmpty(); +}); + +it('removes tabs and rehomes mandatory fields', function () { + Event::listen(NativeFieldsResolving::class, function (NativeFieldsResolving $event) { + $event->fields[] = new TextField([ + 'attribute' => 'title', + 'mandatory' => true, + ]); + }); + + $layout = new FieldLayout; + $layout->tab('Legacy', fn (FieldLayoutTab $tab) => $tab->add(new Heading('Legacy'))); + + $layout->removeTab(FieldLayout::defaultTabName()); + + $mandatoryField = array_find( + $layout->getTab('Legacy')->getElements(), + fn (FieldLayoutElement $element) => $element instanceof TextField && $element->attribute() === 'title', + ); + + expect($layout->getTabs())->toHaveCount(1) + ->and($mandatoryField)->toBeInstanceOf(TextField::class); +}); + +it('round trips config without changing generated uids', function () { + $layout = new FieldLayout; + $layout->tab(FieldLayout::defaultTabName(), fn (FieldLayoutTab $tab) => $tab->add( + CustomField::for(fluentField())->required(), + new Heading('Metadata'), + )); + $config = $layout->getConfig(); + + expect(FieldLayout::createFromConfig($config)->getConfig())->toBe($config); +});