diff --git a/src/Fields/Section.php b/src/Fields/Section.php index 4fa40e8c477..9ad15533b52 100644 --- a/src/Fields/Section.php +++ b/src/Fields/Section.php @@ -7,10 +7,12 @@ class Section { protected $contents; + protected ?Fields $fields; - public function __construct($contents) + public function __construct($contents, ?Fields $fields = null) { $this->contents = $contents; + $this->fields = $fields; } public function display(): ?string @@ -40,7 +42,7 @@ public function contents(): array public function fields(): Fields { - return new Fields(Arr::get($this->contents, 'fields', [])); + return $this->fields ?? new Fields(Arr::get($this->contents, 'fields', [])); } public function toPublishArray(): array @@ -51,7 +53,18 @@ public function toPublishArray(): array 'collapsible' => $this->collapsible() ?: null, 'collapsed' => $this->collapsed() ?: null, ]) + [ - 'fields' => $this->fields()->toPublishArray(), + 'fields' => $this->fieldsToPublishArray(), ]; } + + private function fieldsToPublishArray(): array + { + // Fields::toPublishArray() caches by handle and config, which would + // conflate injected fields that only differ by their prefix. + if ($this->fields) { + return $this->fields->all()->map->toPublishArray()->values()->all(); + } + + return $this->fields()->toPublishArray(); + } } diff --git a/src/Fields/Tab.php b/src/Fields/Tab.php index 0d2280ca517..675220d1531 100644 --- a/src/Fields/Tab.php +++ b/src/Fields/Tab.php @@ -2,6 +2,7 @@ namespace Statamic\Fields; +use Illuminate\Support\Collection; use Statamic\Facades\Fieldset as FieldsetRepository; use Statamic\Support\Arr; use Statamic\Support\Str; @@ -82,7 +83,7 @@ public function toPublishArray() 'display' => $this->display(), 'instructions' => $this->instructions(), 'handle' => $this->handle, - 'sections' => $this->expandedSections(), + 'sections' => $this->expandedSections()->map->toPublishArray()->all(), ]; } @@ -96,13 +97,13 @@ public function instructions() return Arr::get($this->contents, 'instructions'); } - private function expandedSections(): array + public function expandedSections(): Collection { return $this->sections()->reduce(function ($carry, Section $section) { $fields = Arr::get($section->contents(), 'fields', []); if (empty($fields)) { - return $carry->push($section->toPublishArray()); + return $carry->push($section); } $sectionContents = $section->contents(); @@ -119,21 +120,21 @@ private function expandedSections(): array } if (! empty($bufferedFields)) { - $carry->push((new Section($sectionMeta + ['fields' => $bufferedFields]))->toPublishArray()); + $carry->push(new Section($sectionMeta + ['fields' => $bufferedFields])); $bufferedFields = []; } foreach ($importedFieldset->sections() as $importedSection) { - $carry->push($this->toImportedPublishSection($importedSection, $field)); + $carry->push($this->toImportedSection($importedSection, $field)); } } if (! empty($bufferedFields)) { - $carry->push((new Section($sectionMeta + ['fields' => $bufferedFields]))->toPublishArray()); + $carry->push(new Section($sectionMeta + ['fields' => $bufferedFields])); } return $carry; - }, collect())->all(); + }, collect()); } private function sectionedFieldsetImport(array $field): ?Fieldset @@ -151,18 +152,19 @@ private function sectionedFieldsetImport(array $field): ?Fieldset return $fieldset && $fieldset->hasSections() ? $fieldset : null; } - private function toImportedPublishSection(array $section, array $import): array + private function toImportedSection(array $section, array $import): Section { - $fields = (new Fields(Arr::get($section, 'fields', [])))->all(); + $fields = new Fields(Arr::get($section, 'fields', [])); + $resolved = $fields->all(); if ($overrides = $import['config'] ?? null) { - $fields = $fields->map(function ($field, $handle) use ($overrides) { + $resolved = $resolved->map(function ($field, $handle) use ($overrides) { return $field->setConfig(array_merge($field->config(), $overrides[$handle] ?? [])); }); } if ($prefix = Arr::get($import, 'prefix')) { - $fields = $fields->mapWithKeys(function ($field) use ($prefix) { + $resolved = $resolved->mapWithKeys(function ($field) use ($prefix) { $field = clone $field; $handle = $prefix.$field->handle(); $fieldPrefix = $prefix.$field->prefix(); @@ -171,13 +173,9 @@ private function toImportedPublishSection(array $section, array $import): array }); } - return Arr::removeNullValues([ - 'display' => Arr::get($section, 'display'), - 'instructions' => Arr::get($section, 'instructions'), - 'collapsible' => ($collapsible = Arr::get($section, 'collapsible')) ?: null, - 'collapsed' => ($collapsible && Arr::get($section, 'collapsed')) ?: null, - ]) + [ - 'fields' => $fields->map->toPublishArray()->values()->all(), - ]; + return new Section( + Arr::only($section, ['display', 'instructions', 'collapsible', 'collapsed']), + $fields->setFields($resolved) + ); } } diff --git a/tests/Fields/SectionTest.php b/tests/Fields/SectionTest.php index 88d4aa94d51..9c20ce54e95 100644 --- a/tests/Fields/SectionTest.php +++ b/tests/Fields/SectionTest.php @@ -170,4 +170,34 @@ public function converts_to_array_suitable_for_rendering_fields_in_publish_compo ], ], $section->toPublishArray()); } + + #[Test] + public function it_can_be_given_resolved_fields() + { + $fields = (new Fields)->setFields(collect([ + 'seo_title' => (new Field('seo_title', ['type' => 'text']))->setPrefix('seo_'), + ])); + + $section = new Section(['display' => 'SEO', 'fields' => [['handle' => 'ignored', 'field' => ['type' => 'text']]]], $fields); + + $this->assertSame($fields, $section->fields()); + + $publish = $section->toPublishArray(); + $this->assertEquals('SEO', $publish['display']); + $this->assertEquals(['seo_title'], collect($publish['fields'])->pluck('handle')->all()); + $this->assertEquals('seo_', $publish['fields'][0]['prefix']); + } + + #[Test] + public function resolved_fields_dont_share_cached_publish_arrays_with_fields_that_differ_by_prefix() + { + $inline = new Section(['fields' => [['handle' => 'home_city', 'field' => ['type' => 'text']]]]); + + $imported = new Section([], (new Fields)->setFields(collect([ + 'home_city' => (new Field('home_city', ['type' => 'text']))->setPrefix('home_'), + ]))); + + $this->assertNull($inline->toPublishArray()['fields'][0]['prefix']); + $this->assertEquals('home_', $imported->toPublishArray()['fields'][0]['prefix']); + } } diff --git a/tests/Fields/TabTest.php b/tests/Fields/TabTest.php index 1af68e3ae09..51a5882b2fb 100644 --- a/tests/Fields/TabTest.php +++ b/tests/Fields/TabTest.php @@ -4,10 +4,12 @@ use Facades\Statamic\Fields\FieldRepository; use PHPUnit\Framework\Attributes\Test; +use Statamic\Exceptions\FieldsetRecursionException; use Statamic\Facades\Fieldset as FieldsetRepository; use Statamic\Fields\Field; use Statamic\Fields\Fields; use Statamic\Fields\Fieldset; +use Statamic\Fields\Section; use Statamic\Fields\Tab; use Tests\TestCase; @@ -360,4 +362,117 @@ public function it_applies_config_overrides_to_fields_inside_imported_fieldset_s $this->assertNull($metaDescription['instructions']); $this->assertEquals('Meta Description', $metaDescription['display']); } + + #[Test] + public function it_gets_expanded_sections() + { + FieldsetRepository::shouldReceive('find') + ->with('address') + ->andReturn((new Fieldset)->setHandle('address')->setContents([ + 'fields' => [ + ['handle' => 'street', 'field' => ['type' => 'text']], + ], + ])); + + FieldsetRepository::shouldReceive('find') + ->with('seo') + ->andReturn((new Fieldset)->setHandle('seo')->setContents([ + 'sections' => [ + [ + 'display' => 'SEO', + 'instructions' => 'Search engines', + 'collapsible' => true, + 'collapsed' => true, + 'fields' => [ + ['handle' => 'meta_title', 'field' => ['type' => 'text', 'display' => 'Meta Title']], + ['import' => 'address', 'prefix' => 'office_'], + ], + ], + [ + 'display' => 'Social', + 'fields' => [ + ['handle' => 'og_title', 'field' => ['type' => 'text']], + ], + ], + ], + ])); + + $tab = (new Tab('main'))->setContents([ + 'sections' => [ + [ + 'display' => 'Main', + 'fields' => [ + ['handle' => 'title', 'field' => ['type' => 'text']], + [ + 'import' => 'seo', + 'prefix' => 'seo_', + 'config' => [ + 'meta_title' => ['instructions' => 'Keep it short.'], + ], + ], + ], + ], + [ + 'display' => 'Empty', + ], + ], + ]); + + $sections = $tab->expandedSections(); + + $this->assertCount(4, $sections); + $this->assertContainsOnlyInstancesOf(Section::class, $sections); + $this->assertEquals(['Main', 'SEO', 'Social', 'Empty'], $sections->map->display()->all()); + + $this->assertEquals(['title'], $sections[0]->fields()->all()->keys()->all()); + + $seo = $sections[1]; + $this->assertEquals('Search engines', $seo->instructions()); + $this->assertTrue($seo->collapsible()); + $this->assertTrue($seo->collapsed()); + + $fields = $seo->fields()->all(); + $this->assertEquals(['seo_meta_title', 'seo_office_street'], $fields->keys()->all()); + $this->assertEquals('seo_', $fields['seo_meta_title']->prefix()); + $this->assertEquals('seo_office_', $fields['seo_office_street']->prefix()); + $this->assertEquals('Keep it short.', $fields['seo_meta_title']->get('instructions')); + $this->assertEquals('Meta Title', $fields['seo_meta_title']->display()); + + $this->assertEquals(['seo_og_title'], $sections[2]->fields()->all()->keys()->all()); + $this->assertEquals([], $sections[3]->fields()->all()->all()); + + $this->assertCount(2, $tab->sections()); + $this->assertEquals( + ['title', 'seo_meta_title', 'seo_office_street', 'seo_og_title'], + $tab->fields()->all()->keys()->all() + ); + } + + #[Test] + public function it_does_not_allow_recursive_imports_inside_imported_fieldset_sections() + { + $this->expectException(FieldsetRecursionException::class); + + FieldsetRepository::shouldReceive('find') + ->with('seo') + ->andReturn((new Fieldset)->setHandle('seo')->setContents([ + 'sections' => [ + [ + 'fields' => [ + ['import' => 'seo'], + ], + ], + ], + ])); + + (new Tab('main'))->setContents([ + 'sections' => [ + [ + 'fields' => [ + ['import' => 'seo'], + ], + ], + ], + ])->toPublishArray(); + } }